{"type":"file-history-snapshot","messageId":"408ba8ad-f007-4779-8bd3-4632dbffd64b","snapshot":{"messageId":"408ba8ad-f007-4779-8bd3-4632dbffd64b","trackedFileBackups":{},"timestamp":"2026-02-11T14:47:24.859Z"},"isSnapshotUpdate":false}
{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","type":"user","message":{"role":"user","content":"<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>"},"isMeta":true,"uuid":"d4bcfa12-aecc-4b7a-9615-0935c5de3c69","timestamp":"2026-02-11T14:47:24.859Z"}
{"parentUuid":"d4bcfa12-aecc-4b7a-9615-0935c5de3c69","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","type":"user","message":{"role":"user","content":"<command-name>/clear</command-name>\n            <command-message>clear</command-message>\n            <command-args></command-args>"},"uuid":"408ba8ad-f007-4779-8bd3-4632dbffd64b","timestamp":"2026-02-11T14:47:24.853Z"}
{"parentUuid":"408ba8ad-f007-4779-8bd3-4632dbffd64b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","type":"user","message":{"role":"user","content":"<local-command-stdout></local-command-stdout>"},"uuid":"af79599b-64e5-45ba-8911-5d594c6ea78b","timestamp":"2026-02-11T14:47:24.859Z"}
{"type":"file-history-snapshot","messageId":"4439b122-4d92-42bc-8059-b92b46168b00","snapshot":{"messageId":"4439b122-4d92-42bc-8059-b92b46168b00","trackedFileBackups":{},"timestamp":"2026-02-11T14:47:30.950Z"},"isSnapshotUpdate":false}
{"parentUuid":"af79599b-64e5-45ba-8911-5d594c6ea78b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","type":"user","message":{"role":"user","content":"Write a summary of the specs"},"uuid":"4439b122-4d92-42bc-8059-b92b46168b00","timestamp":"2026-02-11T14:47:30.950Z","thinkingMetadata":{"maxThinkingTokens":31999},"permissionMode":"acceptEdits"}
{"parentUuid":"4439b122-4d92-42bc-8059-b92b46168b00","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","message":{"model":"claude-opus-4-6","id":"msg_01M9BJKbEhi5sNYZJbCeWhxT","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":21369,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":21369},"output_tokens":11,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Tu97TyXMEWHf6xd2te","type":"assistant","uuid":"25f1b186-3191-412d-bce1-612376a148cd","timestamp":"2026-02-11T14:47:34.372Z"}
{"parentUuid":"25f1b186-3191-412d-bce1-612376a148cd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","message":{"model":"claude-opus-4-6","id":"msg_01M9BJKbEhi5sNYZJbCeWhxT","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to read the specs directory and write a summary. Let me first find what's in the specs directory.","signature":"EpwCCkYICxgCKkA112gIoq7TSQopNmhcw4E+J5cri1KF/VlLzcMbRKnsJjpXkgZ3Uc1v1QP+fjJ+DIU1kmeZqVSkot3QFlkAHN0mEgx6tm7fUxCSSVVUNfEaDJ2ZTJqjUWkZ7ZcFkyIwvIjkD7mmZmAK/4UQcy/y1S2MOnZmcs9Ka0LObhFhO/vmrsoBe4x3BawYo+lv4w+UKoMBFObocdmFJOmyGXuFx6xEAm7IBETU8cYJHuiT/GhO1QRS7/7qJeDkXVukp57zXUMNA1IofVs15io4ORIcTIMsq+KDdO+cqbZD9gqUplZi+gjLB7MLN9H5HL79om26Klp2zKWH97ZmN5R4TRIFrlm9DDK1C+HFKLjii7B+dnqb87WTuogYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":21369,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":21369},"output_tokens":11,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Tu97TyXMEWHf6xd2te","type":"assistant","uuid":"249ab777-3c1a-4f09-bbf0-96662d1956b3","timestamp":"2026-02-11T14:47:35.280Z"}
{"parentUuid":"249ab777-3c1a-4f09-bbf0-96662d1956b3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01M9BJKbEhi5sNYZJbCeWhxT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PtQ6UpgdPBBKWe9wThLexy","name":"Glob","input":{"pattern":"specs/**/*"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":21369,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":21369},"output_tokens":11,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Tu97TyXMEWHf6xd2te","type":"assistant","uuid":"855f63be-1d72-4014-a3c6-80e461f75e0e","timestamp":"2026-02-11T14:47:35.700Z"}
{"parentUuid":"855f63be-1d72-4014-a3c6-80e461f75e0e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Glob","command":"callback"},"parentToolUseID":"toolu_01PtQ6UpgdPBBKWe9wThLexy","toolUseID":"toolu_01PtQ6UpgdPBBKWe9wThLexy","timestamp":"2026-02-11T14:47:36.178Z","uuid":"3c9fb831-3b56-454d-b0a9-24e9abab7781"}
{"parentUuid":"855f63be-1d72-4014-a3c6-80e461f75e0e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PtQ6UpgdPBBKWe9wThLexy","type":"tool_result","content":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check.md\n/Users/fabianwesner/Herd/shop/specs/claude-spec-check.md\n/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md\n/Users/fabianwesner/Herd/shop/specs/codex-spec-check-02.md\n/Users/fabianwesner/Herd/shop/specs/claude-spec-check-02.md\n/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md\n/Users/fabianwesner/Herd/shop/specs/codex-spec-check-03.md\n/Users/fabianwesner/Herd/shop/specs/claude-spec-check-03.md\n/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md\n/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md\n/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md\n/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md\n/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md\n/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md\n/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md"}]},"uuid":"4bc3691b-f29c-43fa-a5d4-b30e8f61f584","timestamp":"2026-02-11T14:47:36.178Z","toolUseResult":{"filenames":["/Users/fabianwesner/Herd/shop/specs/codex-spec-check.md","/Users/fabianwesner/Herd/shop/specs/claude-spec-check.md","/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","/Users/fabianwesner/Herd/shop/specs/codex-spec-check-02.md","/Users/fabianwesner/Herd/shop/specs/claude-spec-check-02.md","/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","/Users/fabianwesner/Herd/shop/specs/codex-spec-check-03.md","/Users/fabianwesner/Herd/shop/specs/claude-spec-check-03.md","/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md"],"durationMs":474,"numFiles":15,"truncated":false},"sourceToolAssistantUUID":"855f63be-1d72-4014-a3c6-80e461f75e0e"}
{"parentUuid":"4bc3691b-f29c-43fa-a5d4-b30e8f61f584","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MeXJXrnxSfp7VF5BthJgWk","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"6aef87f6-1368-4fab-a181-47e0411fec04","timestamp":"2026-02-11T14:47:38.615Z"}
{"parentUuid":"6aef87f6-1368-4fab-a181-47e0411fec04","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01MeXJXrnxSfp7VF5BthJgWk","toolUseID":"toolu_01MeXJXrnxSfp7VF5BthJgWk","timestamp":"2026-02-11T14:47:38.861Z","uuid":"b6447619-0bfb-461a-ae27-6e4b7578c31a"}
{"parentUuid":"6aef87f6-1368-4fab-a181-47e0411fec04","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MeXJXrnxSfp7VF5BthJgWk","type":"tool_result","content":"     1→# 01 - Database Schema Specification\n     2→\n     3→> Shopify-clone 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_pending', 'completed', 'expired') | |\n   588→| email | TEXT | Yes | NULL | - | Contact email |\n   589→| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n   590→| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n   591→| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n   592→| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n   593→| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n   594→| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n   595→| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n   596→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   597→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   598→\n   599→**Indexes:**\n   600→\n   601→- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n   602→- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n   603→- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n   604→- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n   605→- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n   606→\n   607→**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   608→\n   609→---\n   610→\n   611→### shipping_zones\n   612→\n   613→Geographic shipping zones for a store.\n   614→\n   615→| Column | Type | Nullable | Default | Constraints | Notes |\n   616→|---|---|---|---|---|---|\n   617→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   618→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   619→| name | TEXT | No | - | - | Zone display name |\n   620→| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n   621→| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n   622→\n   623→**Indexes:**\n   624→\n   625→- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n   626→\n   627→**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n   628→\n   629→---\n   630→\n   631→### shipping_rates\n   632→\n   633→Shipping rate options within a zone.\n   634→\n   635→| Column | Type | Nullable | Default | Constraints | Notes |\n   636→|---|---|---|---|---|---|\n   637→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   638→| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n   639→| name | TEXT | No | - | - | Rate display name |\n   640→| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n   641→| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n   642→| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n   643→\n   644→**Indexes:**\n   645→\n   646→- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n   647→- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n   648→\n   649→**Notes:** `config_json` structure depends on `type`:\n   650→- Flat: `{\"amount\": 799}`\n   651→- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n   652→- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n   653→- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n   654→\n   655→---\n   656→\n   657→### tax_settings\n   658→\n   659→Per-store tax configuration.\n   660→\n   661→| Column | Type | Nullable | Default | Constraints | Notes |\n   662→|---|---|---|---|---|---|\n   663→| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n   664→| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n   665→| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n   666→| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n   667→| config_json | TEXT | No | '{}' | - | Provider-specific config |\n   668→\n   669→---\n   670→\n   671→### discounts\n   672→\n   673→Discount codes and automatic discounts.\n   674→\n   675→| Column | Type | Nullable | Default | Constraints | Notes |\n   676→|---|---|---|---|---|---|\n   677→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   678→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   679→| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n   680→| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n   681→| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n   682→| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n   683→| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n   684→| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n   685→| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n   686→| usage_count | INTEGER | No | 0 | - | Current redemption count |\n   687→| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n   688→| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n   689→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   690→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   691→\n   692→**Indexes:**\n   693→\n   694→- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n   695→- `idx_discounts_store_id` on (`store_id`) - FK lookup\n   696→- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n   697→- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n   698→\n   699→**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   700→\n   701→---\n   702→\n   703→## Epic 6 - Payments, Orders, Fulfillment, and Customers\n   704→\n   705→### customers\n   706→\n   707→Storefront customer accounts, scoped to a store.\n   708→\n   709→| Column | Type | Nullable | Default | Constraints | Notes |\n   710→|---|---|---|---|---|---|\n   711→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   712→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   713→| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n   714→| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n   715→| name | TEXT | Yes | NULL | - | Display name |\n   716→| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n   717→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   718→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   719→\n   720→**Indexes:**\n   721→\n   722→- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n   723→- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n   724→\n   725→**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n   726→\n   727→---\n   728→\n   729→### customer_addresses\n   730→\n   731→Saved addresses for a customer.\n   732→\n   733→| Column | Type | Nullable | Default | Constraints | Notes |\n   734→|---|---|---|---|---|---|\n   735→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   736→| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n   737→| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n   738→| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n   739→| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n   740→\n   741→**Indexes:**\n   742→\n   743→- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n   744→- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n   745→\n   746→**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n   747→\n   748→---\n   749→\n   750→### orders\n   751→\n   752→Placed orders with financial and fulfillment tracking.\n   753→\n   754→| Column | Type | Nullable | Default | Constraints | Notes |\n   755→|---|---|---|---|---|---|\n   756→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   757→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   758→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n   759→| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n   760→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n   761→| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n   762→| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n   763→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n   764→| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n   765→| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n   766→| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n   767→| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n   768→| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n   769→| email | TEXT | Yes | NULL | - | Contact email at time of order |\n   770→| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n   771→| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n   772→| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n   773→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   774→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   775→\n   776→**Indexes:**\n   777→\n   778→- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n   779→- `idx_orders_store_id` on (`store_id`) - FK lookup\n   780→- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n   781→- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n   782→- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n   783→- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n   784→- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n   785→\n   786→---\n   787→\n   788→### order_lines\n   789→\n   790→Individual items within an order. Contains snapshots of product data at time of purchase.\n   791→\n   792→| Column | Type | Nullable | Default | Constraints | Notes |\n   793→|---|---|---|---|---|---|\n   794→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   795→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   796→| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n   797→| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n   798→| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n   799→| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n   800→| quantity | INTEGER | No | 1 | - | Number of units |\n   801→| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n   802→| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n   803→| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n   804→| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n   805→\n   806→**Indexes:**\n   807→\n   808→- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n   809→- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n   810→- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n   811→\n   812→**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   813→\n   814→---\n   815→\n   816→### payments\n   817→\n   818→Payment attempts and captures for an order.\n   819→\n   820→| Column | Type | Nullable | Default | Constraints | Notes |\n   821→|---|---|---|---|---|---|\n   822→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   823→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   824→| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n   825→| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |\n   826→| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n   827→| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n   828→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n   829→| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n   830→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   831→\n   832→**Indexes:**\n   833→\n   834→- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n   835→- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID\n   836→- `idx_payments_status` on (`status`) - Filtering payments by status\n   837→\n   838→**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n   839→\n   840→---\n   841→\n   842→### refunds\n   843→\n   844→Refund records linked to an order and payment.\n   845→\n   846→| Column | Type | Nullable | Default | Constraints | Notes |\n   847→|---|---|---|---|---|---|\n   848→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   849→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   850→| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n   851→| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n   852→| reason | TEXT | Yes | NULL | - | Refund reason |\n   853→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n   854→| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n   855→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   856→\n   857→**Indexes:**\n   858→\n   859→- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n   860→- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n   861→- `idx_refunds_status` on (`status`) - Filtering refunds by status\n   862→\n   863→---\n   864→\n   865→### fulfillments\n   866→\n   867→Shipment tracking for order fulfillment.\n   868→\n   869→| Column | Type | Nullable | Default | Constraints | Notes |\n   870→|---|---|---|---|---|---|\n   871→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   872→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   873→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n   874→| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n   875→| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n   876→| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n   877→| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   878→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   879→\n   880→**Indexes:**\n   881→\n   882→- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n   883→- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n   884→- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n   885→\n   886→---\n   887→\n   888→### fulfillment_lines\n   889→\n   890→Links specific order lines and quantities to a fulfillment.\n   891→\n   892→| Column | Type | Nullable | Default | Constraints | Notes |\n   893→|---|---|---|---|---|---|\n   894→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   895→| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n   896→| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n   897→| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n   898→\n   899→**Indexes:**\n   900→\n   901→- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n   902→- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n   903→\n   904→**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   905→\n   906→---\n   907→\n   908→## Epic 7 - Analytics\n   909→\n   910→### analytics_events\n   911→\n   912→Raw event stream for storefront analytics.\n   913→\n   914→| Column | Type | Nullable | Default | Constraints | Notes |\n   915→|---|---|---|---|---|---|\n   916→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   917→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   918→| type | TEXT | No | - | - | Event type (see notes) |\n   919→| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n   920→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n   921→| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n   922→| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n   923→| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n   924→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   925→\n   926→**Indexes:**\n   927→\n   928→- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n   929→- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n   930→- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n   931→- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n   932→- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n   933→- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n   934→\n   935→**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   936→\n   937→---\n   938→\n   939→### analytics_daily\n   940→\n   941→Pre-aggregated daily metrics per store. Composite primary key.\n   942→\n   943→| Column | Type | Nullable | Default | Constraints | Notes |\n   944→|---|---|---|---|---|---|\n   945→| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n   946→| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n   947→| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n   948→| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n   949→| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n   950→| visits_count | INTEGER | No | 0 | - | Unique sessions |\n   951→| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n   952→| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n   953→| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n   954→\n   955→**Primary Key:** (`store_id`, `date`)\n   956→\n   957→**Indexes:**\n   958→\n   959→- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n   960→\n   961→**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   962→\n   963→---\n   964→\n   965→## Epic 8 - Apps and Webhooks\n   966→\n   967→### apps\n   968→\n   969→Third-party or first-party application registry.\n   970→\n   971→| Column | Type | Nullable | Default | Constraints | Notes |\n   972→|---|---|---|---|---|---|\n   973→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   974→| name | TEXT | No | - | - | App display name |\n   975→| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n   976→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   977→\n   978→**Indexes:**\n   979→\n   980→- `idx_apps_status` on (`status`) - Filtering apps by status\n   981→\n   982→---\n   983→\n   984→### app_installations\n   985→\n   986→Installation of an app on a specific store.\n   987→\n   988→| Column | Type | Nullable | Default | Constraints | Notes |\n   989→|---|---|---|---|---|---|\n   990→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   991→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n   992→| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n   993→| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n   994→| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n   995→| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   996→\n   997→**Indexes:**\n   998→\n   999→- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n  1000→- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n  1001→- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n  1002→\n  1003→---\n  1004→\n  1005→### oauth_clients\n  1006→\n  1007→OAuth2 client credentials for apps.\n  1008→\n  1009→| Column | Type | Nullable | Default | Constraints | Notes |\n  1010→|---|---|---|---|---|---|\n  1011→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1012→| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n  1013→| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n  1014→| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n  1015→| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n  1016→\n  1017→**Indexes:**\n  1018→\n  1019→- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n  1020→- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n  1021→\n  1022→---\n  1023→\n  1024→### oauth_tokens\n  1025→\n  1026→Issued access/refresh tokens for app installations.\n  1027→\n  1028→| Column | Type | Nullable | Default | Constraints | Notes |\n  1029→|---|---|---|---|---|---|\n  1030→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1031→| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n  1032→| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n  1033→| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n  1034→| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n  1035→\n  1036→**Indexes:**\n  1037→\n  1038→- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n  1039→- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n  1040→- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n  1041→\n  1042→---\n  1043→\n  1044→### webhook_subscriptions\n  1045→\n  1046→Registered webhook endpoints for event delivery.\n  1047→\n  1048→| Column | Type | Nullable | Default | Constraints | Notes |\n  1049→|---|---|---|---|---|---|\n  1050→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1051→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n  1052→| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n  1053→| event_type | TEXT | No | - | - | Event to subscribe to |\n  1054→| target_url | TEXT | No | - | - | Delivery endpoint URL |\n  1055→| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n  1056→| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n  1057→\n  1058→**Indexes:**\n  1059→\n  1060→- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n  1061→- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n  1062→- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n  1063→\n  1064→---\n  1065→\n  1066→### webhook_deliveries\n  1067→\n  1068→Delivery log for webhook attempts.\n  1069→\n  1070→| Column | Type | Nullable | Default | Constraints | Notes |\n  1071→|---|---|---|---|---|---|\n  1072→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1073→| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n  1074→| event_id | TEXT | No | - | - | Unique event identifier |\n  1075→| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n  1076→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n  1077→| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1078→| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n  1079→| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n  1080→\n  1081→**Indexes:**\n  1082→\n  1083→- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n  1084→- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n  1085→- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n  1086→- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n  1087→\n  1088→---\n  1089→\n  1090→## Laravel Framework Tables\n  1091→\n  1092→The following tables are managed by Laravel's built-in migrations and should not be modified.\n  1093→\n  1094→### sessions\n  1095→\n  1096→| Column | Type | Nullable | Default | Constraints | Notes |\n  1097→|---|---|---|---|---|---|\n  1098→| id | TEXT | No | - | PRIMARY KEY | Session ID |\n  1099→| user_id | INTEGER | Yes | NULL | - | Associated user |\n  1100→| ip_address | TEXT | Yes | NULL | - | Client IP |\n  1101→| user_agent | TEXT | Yes | NULL | - | Client user agent |\n  1102→| payload | TEXT | No | - | - | Serialized session data |\n  1103→| last_activity | INTEGER | No | - | - | Unix timestamp |\n  1104→\n  1105→### cache\n  1106→\n  1107→| Column | Type | Nullable | Default | Constraints | Notes |\n  1108→|---|---|---|---|---|---|\n  1109→| key | TEXT | No | - | PRIMARY KEY | Cache key |\n  1110→| value | TEXT | No | - | - | Serialized cache value |\n  1111→| expiration | INTEGER | No | - | - | Unix timestamp |\n  1112→\n  1113→### cache_locks\n  1114→\n  1115→| Column | Type | Nullable | Default | Constraints | Notes |\n  1116→|---|---|---|---|---|---|\n  1117→| key | TEXT | No | - | PRIMARY KEY | Lock key |\n  1118→| owner | TEXT | No | - | - | Lock owner identifier |\n  1119→| expiration | INTEGER | No | - | - | Unix timestamp |\n  1120→\n  1121→### jobs\n  1122→\n  1123→| Column | Type | Nullable | Default | Constraints | Notes |\n  1124→|---|---|---|---|---|---|\n  1125→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1126→| queue | TEXT | No | - | - | Queue name |\n  1127→| payload | TEXT | No | - | - | Serialized job payload |\n  1128→| attempts | INTEGER | No | - | - | Number of attempts |\n  1129→| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1130→| available_at | INTEGER | No | - | - | Unix timestamp |\n  1131→| created_at | INTEGER | No | - | - | Unix timestamp |\n  1132→\n  1133→### job_batches\n  1134→\n  1135→| Column | Type | Nullable | Default | Constraints | Notes |\n  1136→|---|---|---|---|---|---|\n  1137→| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n  1138→| name | TEXT | No | - | - | Batch name |\n  1139→| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n  1140→| pending_jobs | INTEGER | No | - | - | Pending count |\n  1141→| failed_jobs | INTEGER | No | - | - | Failed count |\n  1142→| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n  1143→| options | TEXT | Yes | NULL | - | Serialized batch options |\n  1144→| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1145→| created_at | INTEGER | No | - | - | Unix timestamp |\n  1146→| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1147→\n  1148→### failed_jobs\n  1149→\n  1150→| Column | Type | Nullable | Default | Constraints | Notes |\n  1151→|---|---|---|---|---|---|\n  1152→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1153→| uuid | TEXT | No | - | UNIQUE | Job UUID |\n  1154→| connection | TEXT | No | - | - | Queue connection name |\n  1155→| queue | TEXT | No | - | - | Queue name |\n  1156→| payload | TEXT | No | - | - | Serialized job payload |\n  1157→| exception | TEXT | No | - | - | Exception trace |\n  1158→| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n  1159→\n  1160→### personal_access_tokens\n  1161→\n  1162→| Column | Type | Nullable | Default | Constraints | Notes |\n  1163→|---|---|---|---|---|---|\n  1164→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1165→| tokenable_type | TEXT | No | - | - | Polymorphic type |\n  1166→| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n  1167→| name | TEXT | No | - | - | Token name |\n  1168→| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n  1169→| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n  1170→| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1171→| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1172→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1173→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1174→\n  1175→### password_reset_tokens\n  1176→\n  1177→| Column | Type | Nullable | Default | Constraints | Notes |\n  1178→|---|---|---|---|---|---|\n  1179→| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n  1180→| token | TEXT | No | - | - | Hashed reset token |\n  1181→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1182→\n  1183→---\n  1184→\n  1185→### customer_password_reset_tokens\n  1186→\n  1187→| Column | Type | Nullable | Default | Constraints | Notes |\n  1188→|---|---|---|---|---|---|\n  1189→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n  1190→| email | TEXT | No | - | - | Customer email |\n  1191→| token | TEXT | No | - | - | Hashed reset token |\n  1192→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1193→\n  1194→**Primary Key:** (`store_id`, `email`)\n  1195→\n  1196→---\n  1197→\n  1198→## Entity Relationship Diagrams\n  1199→\n  1200→### Epic 1 - Foundation and Tenancy\n  1201→\n  1202→```mermaid\n  1203→erDiagram\n  1204→    organizations ||--o{ stores : \"owns\"\n  1205→    stores ||--o{ store_domains : \"has\"\n  1206→    stores ||--o{ store_users : \"links\"\n  1207→    users ||--o{ store_users : \"links\"\n  1208→    stores ||--|| store_settings : \"has\"\n  1209→\n  1210→    organizations {\n  1211→        INTEGER id PK\n  1212→        TEXT name\n  1213→        TEXT billing_email\n  1214→    }\n  1215→    stores {\n  1216→        INTEGER id PK\n  1217→        INTEGER organization_id FK\n  1218→        TEXT name\n  1219→        TEXT handle\n  1220→        TEXT status\n  1221→    }\n  1222→    store_domains {\n  1223→        INTEGER id PK\n  1224→        INTEGER store_id FK\n  1225→        TEXT hostname\n  1226→        TEXT type\n  1227→        INTEGER is_primary\n  1228→    }\n  1229→    users {\n  1230→        INTEGER id PK\n  1231→        TEXT email\n  1232→        TEXT password_hash\n  1233→        TEXT name\n  1234→        TEXT status\n  1235→    }\n  1236→    store_users {\n  1237→        INTEGER store_id PK\n  1238→        INTEGER user_id PK\n  1239→        TEXT role\n  1240→    }\n  1241→    store_settings {\n  1242→        INTEGER store_id PK\n  1243→        TEXT settings_json\n  1244→    }\n  1245→```\n  1246→\n  1247→### Epic 2 - Catalog, Collections, Inventory, and Media\n  1248→\n  1249→```mermaid\n  1250→erDiagram\n  1251→    stores ||--o{ products : \"has\"\n  1252→    stores ||--o{ collections : \"has\"\n  1253→    products ||--o{ product_options : \"defines\"\n  1254→    product_options ||--o{ product_option_values : \"contains\"\n  1255→    products ||--o{ product_variants : \"has\"\n  1256→    product_variants }o--o{ product_option_values : \"variant_option_values\"\n  1257→    product_variants ||--|| inventory_items : \"tracked by\"\n  1258→    stores ||--o{ inventory_items : \"has\"\n  1259→    collections }o--o{ products : \"collection_products\"\n  1260→    products ||--o{ product_media : \"has\"\n  1261→\n  1262→    products {\n  1263→        INTEGER id PK\n  1264→        INTEGER store_id FK\n  1265→        TEXT title\n  1266→        TEXT handle\n  1267→        TEXT status\n  1268→    }\n  1269→    product_options {\n  1270→        INTEGER id PK\n  1271→        INTEGER product_id FK\n  1272→        TEXT name\n  1273→        INTEGER position\n  1274→    }\n  1275→    product_option_values {\n  1276→        INTEGER id PK\n  1277→        INTEGER product_option_id FK\n  1278→        TEXT value\n  1279→        INTEGER position\n  1280→    }\n  1281→    product_variants {\n  1282→        INTEGER id PK\n  1283→        INTEGER product_id FK\n  1284→        TEXT sku\n  1285→        INTEGER price_amount\n  1286→        TEXT status\n  1287→    }\n  1288→    inventory_items {\n  1289→        INTEGER id PK\n  1290→        INTEGER store_id FK\n  1291→        INTEGER variant_id FK\n  1292→        INTEGER quantity_on_hand\n  1293→        INTEGER quantity_reserved\n  1294→        TEXT policy\n  1295→    }\n  1296→    collections {\n  1297→        INTEGER id PK\n  1298→        INTEGER store_id FK\n  1299→        TEXT title\n  1300→        TEXT handle\n  1301→        TEXT type\n  1302→        TEXT status\n  1303→    }\n  1304→    product_media {\n  1305→        INTEGER id PK\n  1306→        INTEGER product_id FK\n  1307→        TEXT type\n  1308→        TEXT storage_key\n  1309→        INTEGER position\n  1310→        TEXT status\n  1311→    }\n  1312→```\n  1313→\n  1314→### Epic 3 - Themes, Storefront, and Localization\n  1315→\n  1316→```mermaid\n  1317→erDiagram\n  1318→    stores ||--o{ themes : \"has\"\n  1319→    themes ||--o{ theme_files : \"contains\"\n  1320→    themes ||--|| theme_settings : \"configured by\"\n  1321→    stores ||--o{ pages : \"has\"\n  1322→    stores ||--o{ navigation_menus : \"has\"\n  1323→    navigation_menus ||--o{ navigation_items : \"contains\"\n  1324→\n  1325→    themes {\n  1326→        INTEGER id PK\n  1327→        INTEGER store_id FK\n  1328→        TEXT name\n  1329→        TEXT status\n  1330→    }\n  1331→    theme_files {\n  1332→        INTEGER id PK\n  1333→        INTEGER theme_id FK\n  1334→        TEXT path\n  1335→        TEXT storage_key\n  1336→    }\n  1337→    theme_settings {\n  1338→        INTEGER theme_id PK\n  1339→        TEXT settings_json\n  1340→    }\n  1341→    pages {\n  1342→        INTEGER id PK\n  1343→        INTEGER store_id FK\n  1344→        TEXT title\n  1345→        TEXT handle\n  1346→        TEXT status\n  1347→    }\n  1348→    navigation_menus {\n  1349→        INTEGER id PK\n  1350→        INTEGER store_id FK\n  1351→        TEXT handle\n  1352→        TEXT title\n  1353→    }\n  1354→    navigation_items {\n  1355→        INTEGER id PK\n  1356→        INTEGER menu_id FK\n  1357→        TEXT type\n  1358→        TEXT label\n  1359→        INTEGER position\n  1360→    }\n  1361→```\n  1362→\n  1363→### Epics 5-6 - Cart, Checkout, Orders, and Payments\n  1364→\n  1365→```mermaid\n  1366→erDiagram\n  1367→    stores ||--o{ customers : \"has\"\n  1368→    customers ||--o{ customer_addresses : \"has\"\n  1369→    stores ||--o{ carts : \"has\"\n  1370→    customers ||--o{ carts : \"owns\"\n  1371→    carts ||--o{ cart_lines : \"contains\"\n  1372→    product_variants ||--o{ cart_lines : \"referenced by\"\n  1373→    stores ||--o{ checkouts : \"has\"\n  1374→    carts ||--o{ checkouts : \"converted to\"\n  1375→    stores ||--o{ orders : \"has\"\n  1376→    customers ||--o{ orders : \"placed\"\n  1377→    orders ||--o{ order_lines : \"contains\"\n  1378→    orders ||--o{ payments : \"paid via\"\n  1379→    orders ||--o{ refunds : \"refunded via\"\n  1380→    payments ||--o{ refunds : \"source of\"\n  1381→    orders ||--o{ fulfillments : \"shipped via\"\n  1382→    fulfillments ||--o{ fulfillment_lines : \"contains\"\n  1383→    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n  1384→\n  1385→    customers {\n  1386→        INTEGER id PK\n  1387→        INTEGER store_id FK\n  1388→        TEXT email\n  1389→        TEXT name\n  1390→    }\n  1391→    carts {\n  1392→        INTEGER id PK\n  1393→        INTEGER store_id FK\n  1394→        INTEGER customer_id FK\n  1395→        TEXT status\n  1396→    }\n  1397→    orders {\n  1398→        INTEGER id PK\n  1399→        INTEGER store_id FK\n  1400→        INTEGER customer_id FK\n  1401→        TEXT order_number\n  1402→        TEXT status\n  1403→        TEXT financial_status\n  1404→        TEXT fulfillment_status\n  1405→        INTEGER total_amount\n  1406→    }\n  1407→    payments {\n  1408→        INTEGER id PK\n  1409→        INTEGER order_id FK\n  1410→        TEXT provider\n  1411→        TEXT status\n  1412→        INTEGER amount\n  1413→    }\n  1414→    fulfillments {\n  1415→        INTEGER id PK\n  1416→        INTEGER order_id FK\n  1417→        TEXT status\n  1418→        TEXT tracking_number\n  1419→    }\n  1420→```\n  1421→\n  1422→### Epics 7-8 - Analytics, Apps, and Webhooks\n  1423→\n  1424→```mermaid\n  1425→erDiagram\n  1426→    stores ||--o{ analytics_events : \"records\"\n  1427→    stores ||--o{ analytics_daily : \"aggregates\"\n  1428→    customers ||--o{ analytics_events : \"generates\"\n  1429→    apps ||--o{ app_installations : \"installed via\"\n  1430→    stores ||--o{ app_installations : \"has\"\n  1431→    apps ||--o{ oauth_clients : \"authenticates via\"\n  1432→    app_installations ||--o{ oauth_tokens : \"issues\"\n  1433→    stores ||--o{ webhook_subscriptions : \"registers\"\n  1434→    app_installations ||--o{ webhook_subscriptions : \"owns\"\n  1435→    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n  1436→\n  1437→    analytics_events {\n  1438→        INTEGER id PK\n  1439→        INTEGER store_id FK\n  1440→        TEXT type\n  1441→        TEXT session_id\n  1442→    }\n  1443→    analytics_daily {\n  1444→        INTEGER store_id PK\n  1445→        TEXT date PK\n  1446→        INTEGER orders_count\n  1447→        INTEGER revenue_amount\n  1448→    }\n  1449→    apps {\n  1450→        INTEGER id PK\n  1451→        TEXT name\n  1452→        TEXT status\n  1453→    }\n  1454→    app_installations {\n  1455→        INTEGER id PK\n  1456→        INTEGER store_id FK\n  1457→        INTEGER app_id FK\n  1458→        TEXT status\n  1459→    }\n  1460→    webhook_subscriptions {\n  1461→        INTEGER id PK\n  1462→        INTEGER store_id FK\n  1463→        TEXT event_type\n  1464→        TEXT target_url\n  1465→    }\n  1466→    webhook_deliveries {\n  1467→        INTEGER id PK\n  1468→        INTEGER subscription_id FK\n  1469→        TEXT event_id\n  1470→        TEXT status\n  1471→    }\n  1472→```\n  1473→\n  1474→---\n  1475→\n  1476→## SQLite-Specific Configuration Requirements\n  1477→\n  1478→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  1479→\n  1480→### Required PRAGMA Settings\n  1481→\n  1482→| Pragma | Value | Purpose |\n  1483→|---|---|---|\n  1484→| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n  1485→| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n  1486→| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n  1487→| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n  1488→| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n  1489→\n  1490→### Laravel Configuration Requirements\n  1491→\n  1492→- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n  1493→- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n  1494→- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n  1495→- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n  1496→- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n  1497→- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n  1498→\n  1499→---\n  1500→\n  1501→## Migration Dependency Graph\n  1502→\n  1503→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  1504→\n  1505→```mermaid\n  1506→flowchart TD\n  1507→    subgraph \"Batch 1 - No Dependencies\"\n  1508→        organizations\n  1509→        apps\n  1510→    end\n  1511→\n  1512→    subgraph \"Batch 2 - Depends on Batch 1\"\n  1513→        stores\n  1514→        users\n  1515→    end\n  1516→\n  1517→    subgraph \"Batch 3 - Depends on Batch 2\"\n  1518→        store_domains\n  1519→        store_users\n  1520→        store_settings\n  1521→        customers\n  1522→        themes\n  1523→        pages\n  1524→        navigation_menus\n  1525→        search_settings\n  1526→        shipping_zones\n  1527→        tax_settings\n  1528→        discounts\n  1529→        products\n  1530→        collections\n  1531→        app_installations\n  1532→        oauth_clients\n  1533→    end\n  1534→\n  1535→    subgraph \"Batch 4 - Depends on Batch 3\"\n  1536→        product_options\n  1537→        product_variants\n  1538→        product_media\n  1539→        collection_products\n  1540→        customer_addresses\n  1541→        carts\n  1542→        navigation_items\n  1543→        theme_files\n  1544→        theme_settings\n  1545→        search_queries\n  1546→        shipping_rates\n  1547→        oauth_tokens\n  1548→        webhook_subscriptions\n  1549→    end\n  1550→\n  1551→    subgraph \"Batch 5 - Depends on Batch 4\"\n  1552→        product_option_values\n  1553→        inventory_items\n  1554→        cart_lines\n  1555→        checkouts\n  1556→        orders\n  1557→        analytics_events\n  1558→        analytics_daily\n  1559→        webhook_deliveries\n  1560→    end\n  1561→\n  1562→    subgraph \"Batch 6 - Depends on Batch 5\"\n  1563→        variant_option_values\n  1564→        order_lines\n  1565→        payments\n  1566→        fulfillments\n  1567→    end\n  1568→\n  1569→    subgraph \"Batch 7 - Depends on Batch 6\"\n  1570→        refunds\n  1571→        fulfillment_lines\n  1572→    end\n  1573→\n  1574→    organizations --> stores\n  1575→    apps --> app_installations\n  1576→    apps --> oauth_clients\n  1577→    stores --> store_domains\n  1578→    stores --> store_users\n  1579→    users --> store_users\n  1580→    stores --> store_settings\n  1581→    stores --> customers\n  1582→    stores --> themes\n  1583→    stores --> pages\n  1584→    stores --> navigation_menus\n  1585→    stores --> search_settings\n  1586→    stores --> shipping_zones\n  1587→    stores --> tax_settings\n  1588→    stores --> discounts\n  1589→    stores --> products\n  1590→    stores --> collections\n  1591→    stores --> app_installations\n  1592→    products --> product_options\n  1593→    products --> product_variants\n  1594→    products --> product_media\n  1595→    collections --> collection_products\n  1596→    products --> collection_products\n  1597→    customers --> customer_addresses\n  1598→    stores --> carts\n  1599→    customers --> carts\n  1600→    navigation_menus --> navigation_items\n  1601→    themes --> theme_files\n  1602→    themes --> theme_settings\n  1603→    stores --> search_queries\n  1604→    shipping_zones --> shipping_rates\n  1605→    app_installations --> oauth_tokens\n  1606→    stores --> webhook_subscriptions\n  1607→    app_installations --> webhook_subscriptions\n  1608→    product_options --> product_option_values\n  1609→    stores --> inventory_items\n  1610→    product_variants --> inventory_items\n  1611→    carts --> cart_lines\n  1612→    product_variants --> cart_lines\n  1613→    stores --> checkouts\n  1614→    carts --> checkouts\n  1615→    customers --> checkouts\n  1616→    stores --> orders\n  1617→    customers --> orders\n  1618→    stores --> analytics_events\n  1619→    customers --> analytics_events\n  1620→    stores --> analytics_daily\n  1621→    webhook_subscriptions --> webhook_deliveries\n  1622→    product_variants --> variant_option_values\n  1623→    product_option_values --> variant_option_values\n  1624→    orders --> order_lines\n  1625→    products --> order_lines\n  1626→    product_variants --> order_lines\n  1627→    orders --> payments\n  1628→    orders --> fulfillments\n  1629→    orders --> refunds\n  1630→    payments --> refunds\n  1631→    fulfillments --> fulfillment_lines\n  1632→    order_lines --> fulfillment_lines\n  1633→```\n  1634→\n  1635→### Batch Details\n  1636→\n  1637→- **Batch 1** (no dependencies): `organizations`, `apps`\n  1638→- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n  1639→- **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  1640→- **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  1641→- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n  1642→- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n  1643→- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n  1644→\n  1645→---\n  1646→\n  1647→## Index Strategy Rationale\n  1648→\n  1649→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  1650→\n  1651→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  1652→\n  1653→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  1654→\n  1655→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  1656→\n  1657→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  1658→\n  1659→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  1660→\n  1661→---\n  1662→\n  1663→## Table Count Summary\n  1664→\n  1665→| Epic | Tables | Count |\n  1666→|---|---|---|\n  1667→| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n  1668→| 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  1669→| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n  1670→| 4 - Search | search_settings, search_queries | 2 |\n  1671→| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n  1672→| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n  1673→| 7 - Analytics | analytics_events, analytics_daily | 2 |\n  1674→| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n  1675→| **Total application tables** | | **46** |\n  1676→| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n  1677→| **Grand total** | | **55** |\n  1678→"}]},"uuid":"ed27bf2c-2cc9-463f-8d8c-346b78a9d06d","timestamp":"2026-02-11T14:47:38.861Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","content":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","numLines":1678,"startLine":1,"totalLines":1678}},"sourceToolAssistantUUID":"6aef87f6-1368-4fab-a181-47e0411fec04"}
{"parentUuid":"ed27bf2c-2cc9-463f-8d8c-346b78a9d06d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GyAUanZNeRD81KU9LumamH","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"7f584dab-8a16-43a3-8f9d-498f6a065bab","timestamp":"2026-02-11T14:47:38.987Z"}
{"parentUuid":"7f584dab-8a16-43a3-8f9d-498f6a065bab","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File content (30312 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_01GyAUanZNeRD81KU9LumamH"}]},"uuid":"b9cc1bfd-67de-4f42-aae3-784b21647957","timestamp":"2026-02-11T14:47:39.199Z","toolUseResult":"Error: File content (30312 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":"7f584dab-8a16-43a3-8f9d-498f6a065bab"}
{"parentUuid":"b9cc1bfd-67de-4f42-aae3-784b21647957","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LBs7sViPZpcKmCWv7vBmxj","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"59f0e0b7-bfb0-4cd1-acae-f3538e38d69a","timestamp":"2026-02-11T14:47:39.410Z"}
{"parentUuid":"59f0e0b7-bfb0-4cd1-acae-f3538e38d69a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01LBs7sViPZpcKmCWv7vBmxj"}]},"uuid":"bf040320-6eb6-4ef6-af9d-1f706c4aaa52","timestamp":"2026-02-11T14:47:39.411Z","toolUseResult":"Sibling tool call errored","sourceToolAssistantUUID":"59f0e0b7-bfb0-4cd1-acae-f3538e38d69a"}
{"parentUuid":"bf040320-6eb6-4ef6-af9d-1f706c4aaa52","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BzfN5x4gmf6moMuEKdWB2x","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"b690f51e-fa4f-49ff-bc37-4f7f07b31726","timestamp":"2026-02-11T14:47:39.827Z"}
{"parentUuid":"b690f51e-fa4f-49ff-bc37-4f7f07b31726","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01BzfN5x4gmf6moMuEKdWB2x"}]},"uuid":"91bda874-9b87-45ca-9b3b-3cec9fb37c84","timestamp":"2026-02-11T14:47:39.827Z","toolUseResult":"Sibling tool call errored","sourceToolAssistantUUID":"b690f51e-fa4f-49ff-bc37-4f7f07b31726"}
{"parentUuid":"91bda874-9b87-45ca-9b3b-3cec9fb37c84","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FFTwMZBtGB2rhokUtwYAZk","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"612e2bd3-2f9a-4a36-8009-15d369f61358","timestamp":"2026-02-11T14:47:40.307Z"}
{"parentUuid":"612e2bd3-2f9a-4a36-8009-15d369f61358","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01FFTwMZBtGB2rhokUtwYAZk"}]},"uuid":"d1e343dc-c6ec-4787-bca7-a6f85e8624d3","timestamp":"2026-02-11T14:47:40.307Z","toolUseResult":"Sibling tool call errored","sourceToolAssistantUUID":"612e2bd3-2f9a-4a36-8009-15d369f61358"}
{"parentUuid":"d1e343dc-c6ec-4787-bca7-a6f85e8624d3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AAw5KzSL7erL86AWW6ifQz","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"cd3c9b8d-d2c8-49ea-af41-500c5c1170e8","timestamp":"2026-02-11T14:47:40.717Z"}
{"parentUuid":"cd3c9b8d-d2c8-49ea-af41-500c5c1170e8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01AAw5KzSL7erL86AWW6ifQz"}]},"uuid":"77bce8a1-b1c9-4d04-bc14-711c3b4fc6e3","timestamp":"2026-02-11T14:47:40.718Z","toolUseResult":"Sibling tool call errored","sourceToolAssistantUUID":"cd3c9b8d-d2c8-49ea-af41-500c5c1170e8"}
{"parentUuid":"77bce8a1-b1c9-4d04-bc14-711c3b4fc6e3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YAGWy2k3jdSnrp9SoNtSbd","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"917eda6d-592b-477f-a734-b75ea0429634","timestamp":"2026-02-11T14:47:41.232Z"}
{"parentUuid":"917eda6d-592b-477f-a734-b75ea0429634","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01YAGWy2k3jdSnrp9SoNtSbd"}]},"uuid":"2f9df121-4a6d-49e4-88a5-8ce983d43f9b","timestamp":"2026-02-11T14:47:41.233Z","toolUseResult":"Sibling tool call errored","sourceToolAssistantUUID":"917eda6d-592b-477f-a734-b75ea0429634"}
{"parentUuid":"2f9df121-4a6d-49e4-88a5-8ce983d43f9b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NVS4ZdZgPMRmnfUBRnLGKu","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"f6d72579-9386-42ae-86e3-cd84b9842a9b","timestamp":"2026-02-11T14:47:41.632Z"}
{"parentUuid":"f6d72579-9386-42ae-86e3-cd84b9842a9b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01NVS4ZdZgPMRmnfUBRnLGKu"}]},"uuid":"c09641ef-c010-4d5b-90d9-8a278760c255","timestamp":"2026-02-11T14:47:41.632Z","toolUseResult":"Sibling tool call errored","sourceToolAssistantUUID":"f6d72579-9386-42ae-86e3-cd84b9842a9b"}
{"parentUuid":"c09641ef-c010-4d5b-90d9-8a278760c255","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SVo51uWx4YmaemCp9FWt2s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VUaUXpmoAdWEos6ZzXAV7J","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":519,"cache_read_input_tokens":42055,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":519},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TuSSGKhzx4B9Br1XPV","type":"assistant","uuid":"d3a49b64-b4ee-4651-b405-935a979f525e","timestamp":"2026-02-11T14:47:42.043Z"}
{"parentUuid":"d3a49b64-b4ee-4651-b405-935a979f525e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01VUaUXpmoAdWEos6ZzXAV7J"}]},"uuid":"b757eebe-b098-4721-ad93-3e44381fb9af","timestamp":"2026-02-11T14:47:42.043Z","toolUseResult":"Sibling tool call errored","sourceToolAssistantUUID":"d3a49b64-b4ee-4651-b405-935a979f525e"}
{"parentUuid":"b757eebe-b098-4721-ad93-3e44381fb9af","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Some files were too large to read in parallel. Let me read them individually with limits or use the Explore agent to summarize them.","signature":"Eq0CCkYICxgCKkB2ne1a8k+S7mv7Pku4W+h7T+yOxFju/p6Z84GnS6QPbyzy8QfPAP6QNKtA6pZCwoRoZxPM5OxMWFNwShAMSglZEgw79U6FvpYNUpKG2DgaDMpwDBigiXN2RydL3SIwg9dru2t0OKTgxHZ82hjNh2KKe2sloOTPhhuk9mqA9vSxNwWR1sLZVwI7KScBFhYlKpQBg+CF5sxPn0JTDilvuEB2ZnMCPwBzmgfSW314H+0Y/leWh+5Fp0yssyvBPruvA126z01sbbLZDwjl2mc/4GShSdG+mFfMfB7pVD3E8pfXpWyP3N4Gkh8fP4fQEuj3G2UM1gP+/9YHQBcGPkjutjI2DSa0d2f3refA4V1R/fQb1cX0HbuZ80YLFDrdpG6/E5HR134DvRgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"e299e250-b226-479e-9051-c019a607f353","timestamp":"2026-02-11T14:47:45.392Z"}
{"parentUuid":"e299e250-b226-479e-9051-c019a607f353","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the remaining spec files."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"7af5fbab-3324-4a24-a415-3101598f5bab","timestamp":"2026-02-11T14:47:45.743Z"}
{"parentUuid":"7af5fbab-3324-4a24-a415-3101598f5bab","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FqQRGcoh4xNECxxZEt7QaC","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"45ef9a89-def0-430f-965c-6305c2b4aaca","timestamp":"2026-02-11T14:47:47.129Z"}
{"parentUuid":"45ef9a89-def0-430f-965c-6305c2b4aaca","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01FqQRGcoh4xNECxxZEt7QaC","toolUseID":"toolu_01FqQRGcoh4xNECxxZEt7QaC","timestamp":"2026-02-11T14:47:47.133Z","uuid":"3dc0db01-939e-434d-ac9f-afc9a6b98727"}
{"parentUuid":"45ef9a89-def0-430f-965c-6305c2b4aaca","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FqQRGcoh4xNECxxZEt7QaC","type":"tool_result","content":"     1→# API and Routing Specification\n     2→\n     3→This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n     4→\n     5→All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n     6→\n     7→---\n     8→\n     9→## 1. Web Routes (Blade/Livewire Pages)\n    10→\n    11→### 1.1 Admin Auth Routes\n    12→\n    13→No middleware beyond the default `web` group. These routes handle admin login and logout.\n    14→\n    15→| Method | Path | Description | Component / Action |\n    16→|--------|------|-------------|--------------------|\n    17→| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n    18→| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n    19→| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n    20→| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n    21→| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n    22→| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n    23→| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n    24→\n    25→Rate limit: `login` tier (5 per minute per IP).\n    26→\n    27→---\n    28→\n    29→### 1.2 Admin Routes\n    30→\n    31→**Prefix:** `/admin`\n    32→**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n    33→\n    34→All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n    35→\n    36→> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n    37→\n    38→#### Dashboard\n    39→\n    40→| Method | Path | Description | Component |\n    41→|--------|------|-------------|-----------|\n    42→| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n    43→\n    44→#### Products\n    45→\n    46→| Method | Path | Description | Component |\n    47→|--------|------|-------------|-----------|\n    48→| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n    49→| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n    50→| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n    51→\n    52→#### Inventory\n    53→\n    54→| Method | Path | Description | Component |\n    55→|--------|------|-------------|-----------|\n    56→| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n    57→\n    58→#### Collections\n    59→\n    60→| Method | Path | Description | Component |\n    61→|--------|------|-------------|-----------|\n    62→| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n    63→| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n    64→| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n    65→\n    66→#### Orders\n    67→\n    68→| Method | Path | Description | Component |\n    69→|--------|------|-------------|-----------|\n    70→| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n    71→| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n    72→\n    73→#### Customers\n    74→\n    75→| Method | Path | Description | Component |\n    76→|--------|------|-------------|-----------|\n    77→| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n    78→| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n    79→\n    80→#### Discounts\n    81→\n    82→| Method | Path | Description | Component |\n    83→|--------|------|-------------|-----------|\n    84→| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n    85→| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n    86→| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n    87→\n    88→#### Settings\n    89→\n    90→| Method | Path | Description | Component |\n    91→|--------|------|-------------|-----------|\n    92→| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n    93→| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n    94→| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n    95→\n    96→#### Themes and Online Store\n    97→\n    98→| Method | Path | Description | Component |\n    99→|--------|------|-------------|-----------|\n   100→| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |"}]},"uuid":"a74c853d-00b9-48c2-bcb6-42ead530ca6b","timestamp":"2026-02-11T14:47:47.132Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |","numLines":100,"startLine":1,"totalLines":3383}},"sourceToolAssistantUUID":"45ef9a89-def0-430f-965c-6305c2b4aaca"}
{"parentUuid":"a74c853d-00b9-48c2-bcb6-42ead530ca6b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MCmMNNSHWKps1WjMa3jXbc","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"44a0772b-df42-4aef-9ab9-bbd855e9ad57","timestamp":"2026-02-11T14:47:47.799Z"}
{"parentUuid":"44a0772b-df42-4aef-9ab9-bbd855e9ad57","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01MCmMNNSHWKps1WjMa3jXbc","toolUseID":"toolu_01MCmMNNSHWKps1WjMa3jXbc","timestamp":"2026-02-11T14:47:47.802Z","uuid":"a6729026-ecc6-4e66-92bf-b198610fd4f2"}
{"parentUuid":"44a0772b-df42-4aef-9ab9-bbd855e9ad57","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MCmMNNSHWKps1WjMa3jXbc","type":"tool_result","content":"     1→# 03 - Admin Panel UI/UX Specification\n     2→\n     3→This document defines every view, component, and interaction for the Shopify-clone admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.\n     4→\n     5→**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip\n     6→\n     7→Anything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.\n     8→\n     9→---\n    10→\n    11→## 1. Admin Layout Shell\n    12→\n    13→The admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.\n    14→\n    15→### 1.1 Route Prefix\n    16→\n    17→All admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.\n    18→\n    19→### 1.2 Sidebar Navigation\n    20→\n    21→**Livewire component:** `App\\Livewire\\Admin\\Layout\\Sidebar`\n    22→\n    23→**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`\n    24→\n    25→#### Component Requirements\n    26→\n    27→| Property | Type | Purpose |\n    28→|----------|------|---------|\n    29→| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |\n    30→| `currentRoute` | `string` | The current route name for active highlighting |\n    31→\n    32→| Action | Behavior |\n    33→|--------|----------|\n    34→| `toggle()` | Toggles the `collapsed` state |\n    35→\n    36→#### Wireframe\n    37→\n    38→```\n    39→+-------------------------------+\n    40→|  [BRAND LOGO]  Platform Name  |\n    41→|-------------------------------|\n    42→|  [separator]                  |\n    43→|                               |\n    44→|  [chart-bar]    Dashboard     |\n    45→|                               |\n    46→|  PRODUCTS                     |\n    47→|  [cube]         Products      |\n    48→|  [rect-stack]   Collections   |\n    49→|  [archive-box]  Inventory     |\n    50→|                               |\n    51→|  [separator]                  |\n    52→|  ORDERS                       |\n    53→|  [shopping-bag] Orders        |\n    54→|                               |\n    55→|  [separator]                  |\n    56→|  CUSTOMERS                    |\n    57→|  [users]        Customers     |\n    58→|                               |\n    59→|  [separator]                  |\n    60→|  DISCOUNTS                    |\n    61→|  [tag]          Discounts     |\n    62→|                               |\n    63→|  [separator]                  |\n    64→|  CONTENT                      |\n    65→|  [doc-text]     Pages         |\n    66→|  [bars-3]       Navigation    |\n    67→|  [paint-brush]  Themes        |\n    68→|                               |\n    69→|  [separator]                  |\n    70→|  [chart-pie]    Analytics     |\n    71→|  [cog-6-tooth]  Settings      |\n    72→|  [squares-2x2]  Apps          |\n    73→|  [code-bracket] Developers    |\n    74→+-------------------------------+\n    75→```\n    76→\n    77→#### Navigation Structure\n    78→\n    79→```\n    80→Dashboard                        -> admin.dashboard\n    81→Products (group label)\n    82→  Products                       -> admin.products.index\n    83→  Collections                    -> admin.collections.index\n    84→  Inventory                      -> admin.inventory.index\n    85→Orders\n    86→  Orders                         -> admin.orders.index\n    87→Customers\n    88→  Customers                      -> admin.customers.index\n    89→Discounts\n    90→  Discounts                      -> admin.discounts.index\n    91→Content (group label)\n    92→  Pages                          -> admin.pages.index\n    93→  Navigation                     -> admin.navigation.index\n    94→  Themes                         -> admin.themes.index\n    95→Analytics                        -> admin.analytics.index\n    96→Settings                         -> admin.settings.index\n    97→Apps                             -> admin.apps.index\n    98→Developers                       -> admin.developers.index\n    99→```\n   100→"}]},"uuid":"d81f7a7f-9dd2-48b8-8162-6b1496f125f4","timestamp":"2026-02-11T14:47:47.802Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","content":"# 03 - Admin Panel UI/UX Specification\n\nThis document defines every view, component, and interaction for the Shopify-clone admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.\n\n**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip\n\nAnything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.\n\n---\n\n## 1. Admin Layout Shell\n\nThe admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.\n\n### 1.1 Route Prefix\n\nAll admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.\n\n### 1.2 Sidebar Navigation\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\Sidebar`\n\n**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |\n| `currentRoute` | `string` | The current route name for active highlighting |\n\n| Action | Behavior |\n|--------|----------|\n| `toggle()` | Toggles the `collapsed` state |\n\n#### Wireframe\n\n```\n+-------------------------------+\n|  [BRAND LOGO]  Platform Name  |\n|-------------------------------|\n|  [separator]                  |\n|                               |\n|  [chart-bar]    Dashboard     |\n|                               |\n|  PRODUCTS                     |\n|  [cube]         Products      |\n|  [rect-stack]   Collections   |\n|  [archive-box]  Inventory     |\n|                               |\n|  [separator]                  |\n|  ORDERS                       |\n|  [shopping-bag] Orders        |\n|                               |\n|  [separator]                  |\n|  CUSTOMERS                    |\n|  [users]        Customers     |\n|                               |\n|  [separator]                  |\n|  DISCOUNTS                    |\n|  [tag]          Discounts     |\n|                               |\n|  [separator]                  |\n|  CONTENT                      |\n|  [doc-text]     Pages         |\n|  [bars-3]       Navigation    |\n|  [paint-brush]  Themes        |\n|                               |\n|  [separator]                  |\n|  [chart-pie]    Analytics     |\n|  [cog-6-tooth]  Settings      |\n|  [squares-2x2]  Apps          |\n|  [code-bracket] Developers    |\n+-------------------------------+\n```\n\n#### Navigation Structure\n\n```\nDashboard                        -> admin.dashboard\nProducts (group label)\n  Products                       -> admin.products.index\n  Collections                    -> admin.collections.index\n  Inventory                      -> admin.inventory.index\nOrders\n  Orders                         -> admin.orders.index\nCustomers\n  Customers                      -> admin.customers.index\nDiscounts\n  Discounts                      -> admin.discounts.index\nContent (group label)\n  Pages                          -> admin.pages.index\n  Navigation                     -> admin.navigation.index\n  Themes                         -> admin.themes.index\nAnalytics                        -> admin.analytics.index\nSettings                         -> admin.settings.index\nApps                             -> admin.apps.index\nDevelopers                       -> admin.developers.index\n```\n","numLines":100,"startLine":1,"totalLines":2403}},"sourceToolAssistantUUID":"44a0772b-df42-4aef-9ab9-bbd855e9ad57"}
{"parentUuid":"d81f7a7f-9dd2-48b8-8162-6b1496f125f4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012LhUwdnXf5jVQHMGzedzbu","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"4bd9cc93-60c3-4592-ab1a-8b878323acec","timestamp":"2026-02-11T14:47:48.397Z"}
{"parentUuid":"4bd9cc93-60c3-4592-ab1a-8b878323acec","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_012LhUwdnXf5jVQHMGzedzbu","toolUseID":"toolu_012LhUwdnXf5jVQHMGzedzbu","timestamp":"2026-02-11T14:47:48.402Z","uuid":"35ae33c7-d4eb-475e-b837-736689b773a6"}
{"parentUuid":"4bd9cc93-60c3-4592-ab1a-8b878323acec","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012LhUwdnXf5jVQHMGzedzbu","type":"tool_result","content":"     1→# 04 - Storefront UI/UX Specification\n     2→\n     3→> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n     4→\n     5→---\n     6→\n     7→## Conventions\n     8→\n     9→| Convention | Detail |\n    10→|---|---|\n    11→| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n    12→| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n    13→| Templating | Blade templates with reusable storefront-prefixed components |\n    14→| Accessibility | WCAG 2.2 AA compliance throughout |\n    15→| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n    16→| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n    17→| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n    18→\n    19→---\n    20→\n    21→## Currency Formatting\n    22→\n    23→All monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n    24→\n    25→| Setting | Value |\n    26→|---------|-------|\n    27→| Decimal separator | `.` (period) |\n    28→| Thousands separator | `,` (comma) |\n    29→| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n    30→| Decimal places | Always 2 |\n    31→| Free amounts | Display as `0.00 EUR` |\n    32→| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n    33→\n    34→**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n    35→\n    36→**Examples:**\n    37→- 2499 cents -> `24.99 EUR`\n    38→- 0 cents -> `0.00 EUR`\n    39→- 149900 cents -> `1,499.00 EUR`\n    40→\n    41→---\n    42→\n    43→## 1. Theme System Architecture\n    44→\n    45→### 1.1 View Organization\n    46→\n    47→The storefront views are organized into the following categories:\n    48→\n    49→| Category | Purpose | Views |\n    50→|---|---|---|\n    51→| Layouts | Master page shell | app (master layout) |\n    52→| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n    53→| Checkout | Purchase flow | checkout index, order confirmation |\n    54→| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n    55→| Errors | Error states | 404, 503 |\n    56→| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n    57→\n    58→### 1.2 Layout Hierarchy\n    59→\n    60→All storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n    61→\n    62→### 1.3 Theme Settings\n    63→\n    64→Theme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n    65→\n    66→- Announcement bar visibility, text, link, and background color\n    67→- Sticky header toggle\n    68→- Home page section order and visibility\n    69→- Color palette overrides (primary, secondary, accent)\n    70→- Typography scale\n    71→- Footer content and social links\n    72→- Dark mode behavior (system preference, toggle, or forced)\n    73→\n    74→### 1.4 Section-Based Architecture\n    75→\n    76→Each page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n    77→\n    78→| Section | Description | Used On |\n    79→|---|---|---|\n    80→| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n    81→| Featured Collections | Grid of collection cards | Home |\n    82→| Featured Products | Grid of product cards (lazy-loaded) | Home |\n    83→| Newsletter | Email signup form | Home, Footer |\n    84→| Rich Text | Customizable content block | Home, Pages |\n    85→\n    86→### 1.5 Blade Components\n    87→\n    88→All reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n    89→\n    90→---\n    91→\n    92→## 2. Base Layout\n    93→\n    94→### 2.1 Document Head\n    95→\n    96→The document head includes:\n    97→\n    98→| Element | Description |\n    99→|---|---|\n   100→| Character encoding | UTF-8 |"}]},"uuid":"7a62882e-6e0e-4712-a0d5-bd74c7077d30","timestamp":"2026-02-11T14:47:48.401Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"# 04 - Storefront UI/UX Specification\n\n> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n| Templating | Blade templates with reusable storefront-prefixed components |\n| Accessibility | WCAG 2.2 AA compliance throughout |\n| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n\n---\n\n## Currency Formatting\n\nAll monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n\n| Setting | Value |\n|---------|-------|\n| Decimal separator | `.` (period) |\n| Thousands separator | `,` (comma) |\n| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n| Decimal places | Always 2 |\n| Free amounts | Display as `0.00 EUR` |\n| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n\n**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n\n**Examples:**\n- 2499 cents -> `24.99 EUR`\n- 0 cents -> `0.00 EUR`\n- 149900 cents -> `1,499.00 EUR`\n\n---\n\n## 1. Theme System Architecture\n\n### 1.1 View Organization\n\nThe storefront views are organized into the following categories:\n\n| Category | Purpose | Views |\n|---|---|---|\n| Layouts | Master page shell | app (master layout) |\n| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n| Checkout | Purchase flow | checkout index, order confirmation |\n| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n| Errors | Error states | 404, 503 |\n| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n\n### 1.2 Layout Hierarchy\n\nAll storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n\n### 1.3 Theme Settings\n\nTheme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n\n- Announcement bar visibility, text, link, and background color\n- Sticky header toggle\n- Home page section order and visibility\n- Color palette overrides (primary, secondary, accent)\n- Typography scale\n- Footer content and social links\n- Dark mode behavior (system preference, toggle, or forced)\n\n### 1.4 Section-Based Architecture\n\nEach page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n\n| Section | Description | Used On |\n|---|---|---|\n| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n| Featured Collections | Grid of collection cards | Home |\n| Featured Products | Grid of product cards (lazy-loaded) | Home |\n| Newsletter | Email signup form | Home, Footer |\n| Rich Text | Customizable content block | Home, Pages |\n\n### 1.5 Blade Components\n\nAll reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n\n---\n\n## 2. Base Layout\n\n### 2.1 Document Head\n\nThe document head includes:\n\n| Element | Description |\n|---|---|\n| Character encoding | UTF-8 |","numLines":100,"startLine":1,"totalLines":1835}},"sourceToolAssistantUUID":"4bd9cc93-60c3-4592-ab1a-8b878323acec"}
{"parentUuid":"7a62882e-6e0e-4712-a0d5-bd74c7077d30","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UXP2UvyS6rATd1Cw27pLUR","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"8fa76d30-4e23-4382-ad06-b754890b7a20","timestamp":"2026-02-11T14:47:48.995Z"}
{"parentUuid":"8fa76d30-4e23-4382-ad06-b754890b7a20","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01UXP2UvyS6rATd1Cw27pLUR","toolUseID":"toolu_01UXP2UvyS6rATd1Cw27pLUR","timestamp":"2026-02-11T14:47:48.998Z","uuid":"b04914e6-d3f3-40b5-a5da-7435e14c84a8"}
{"parentUuid":"8fa76d30-4e23-4382-ad06-b754890b7a20","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UXP2UvyS6rATd1Cw27pLUR","type":"tool_result","content":"     1→# 05 - Business Logic Specification\n     2→\n     3→> Shopify-clone 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 |"}]},"uuid":"8dce3d0d-7b2c-4839-b2ce-155ea1d40179","timestamp":"2026-02-11T14:47:48.997Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |","numLines":100,"startLine":1,"totalLines":1583}},"sourceToolAssistantUUID":"8fa76d30-4e23-4382-ad06-b754890b7a20"}
{"parentUuid":"8dce3d0d-7b2c-4839-b2ce-155ea1d40179","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LFxbVDH8aKufJ1NdpX1VFZ","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"e51061fc-57c7-4ee6-af44-1dcb9687f20e","timestamp":"2026-02-11T14:47:49.570Z"}
{"parentUuid":"e51061fc-57c7-4ee6-af44-1dcb9687f20e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01LFxbVDH8aKufJ1NdpX1VFZ","toolUseID":"toolu_01LFxbVDH8aKufJ1NdpX1VFZ","timestamp":"2026-02-11T14:47:49.573Z","uuid":"a4c5f274-2f0d-4ce7-a5d9-175d3c0b0b26"}
{"parentUuid":"e51061fc-57c7-4ee6-af44-1dcb9687f20e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LFxbVDH8aKufJ1NdpX1VFZ","type":"tool_result","content":"     1→# 06 - Authentication and Security Specification\n     2→\n     3→> Shopify-clone e-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite\n     4→\n     5→---\n     6→\n     7→## 1. Authentication\n     8→\n     9→The platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.\n    10→\n    11→### 1.1 Admin Authentication\n    12→\n    13→| Setting | Value |\n    14→|---------|-------|\n    15→| Guard name | `web` (default Laravel guard) |\n    16→| Provider name | `users` (Eloquent driver) |\n    17→| Model | `App\\Models\\User` |\n    18→| Driver | Session (cookie-based) |\n    19→| Login route | `GET /admin/login` |\n    20→| Login form | Livewire component `Admin\\Auth\\Login` |\n    21→\n    22→#### Admin Login Flow\n    23→\n    24→```mermaid\n    25→sequenceDiagram\n    26→    participant B as Browser\n    27→    participant LW as Livewire Component\n    28→    participant Auth as Auth Guard (web)\n    29→    participant DB as Database\n    30→    participant S as Session\n    31→\n    32→    B->>LW: Submit email + password\n    33→    LW->>LW: Validate input (email required, password required)\n    34→    LW->>LW: Check rate limiter (5/min per IP)\n    35→    alt Rate limit exceeded\n    36→        LW-->>B: Flash \"Too many attempts. Try again in X seconds.\"\n    37→    end\n    38→    LW->>Auth: attempt(credentials, remember)\n    39→    Auth->>DB: SELECT from users WHERE email = ?\n    40→    Auth->>Auth: Verify bcrypt hash\n    41→    alt Credentials valid\n    42→        Auth->>S: Regenerate session ID (prevent fixation)\n    43→        Auth-->>LW: Success\n    44→        LW-->>B: Redirect to /admin (dashboard)\n    45→    else Credentials invalid\n    46→        Auth-->>LW: Failure\n    47→        LW-->>B: Flash \"Invalid credentials\" (generic, never reveal which field)\n    48→    end\n    49→```\n    50→\n    51→**Requirements:**\n    52→\n    53→- The login form collects `email` and `password`.\n    54→- On successful login, the session MUST be regenerated to prevent session fixation attacks.\n    55→- On failed login, only the generic message \"Invalid credentials\" is shown. The system must not indicate whether the email or password was wrong.\n    56→- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.\n    57→- After login: redirect to `/admin` (the dashboard).\n    58→\n    59→#### Remember Me\n    60→\n    61→- An optional \"Remember me\" checkbox on the login form.\n    62→- When enabled, Laravel sets a long-lived remember token cookie.\n    63→- The `remember_token` column on the `users` table stores the hashed token.\n    64→- Cookie name follows Laravel default convention: `remember_web_{hash}`.\n    65→\n    66→#### Admin Password Reset Flow\n    67→\n    68→```mermaid\n    69→sequenceDiagram\n    70→    participant B as Browser\n    71→    participant App as Application\n    72→    participant DB as Database\n    73→    participant Mail as Mail Service\n    74→\n    75→    B->>App: GET /admin/forgot-password\n    76→    App-->>B: Render forgot-password form\n    77→    B->>App: POST /admin/forgot-password (email)\n    78→    App->>DB: Look up user by email\n    79→    alt User exists\n    80→        App->>DB: Store reset token in password_reset_tokens table\n    81→        App->>Mail: Send reset link email\n    82→    end\n    83→    App-->>B: \"If that email exists, we sent a reset link.\" (generic)\n    84→    B->>App: GET /admin/reset-password/{token}\n    85→    App-->>B: Render reset-password form\n    86→    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)\n    87→    App->>DB: Validate token (exists, not expired, matches email)\n    88→    alt Valid token\n    89→        App->>DB: Update user password (bcrypt hash)\n    90→        App->>DB: Delete used token\n    91→        App-->>B: Redirect to /admin/login with success flash\n    92→    else Invalid/expired token\n    93→        App-->>B: Error message\n    94→    end\n    95→```\n    96→\n    97→**Requirements:**\n    98→\n    99→- Uses Laravel's built-in password broker (`users` broker).\n   100→- Reset tokens stored in the `password_reset_tokens` table."}]},"uuid":"1c323b29-87d0-4bf1-8ddb-3da02ed113f7","timestamp":"2026-02-11T14:47:49.572Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"# 06 - Authentication and Security Specification\n\n> Shopify-clone e-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite\n\n---\n\n## 1. Authentication\n\nThe platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.\n\n### 1.1 Admin Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `web` (default Laravel guard) |\n| Provider name | `users` (Eloquent driver) |\n| Model | `App\\Models\\User` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /admin/login` |\n| Login form | Livewire component `Admin\\Auth\\Login` |\n\n#### Admin Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (web)\n    participant DB as Database\n    participant S as Session\n\n    B->>LW: Submit email + password\n    LW->>LW: Validate input (email required, password required)\n    LW->>LW: Check rate limiter (5/min per IP)\n    alt Rate limit exceeded\n        LW-->>B: Flash \"Too many attempts. Try again in X seconds.\"\n    end\n    LW->>Auth: attempt(credentials, remember)\n    Auth->>DB: SELECT from users WHERE email = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Credentials valid\n        Auth->>S: Regenerate session ID (prevent fixation)\n        Auth-->>LW: Success\n        LW-->>B: Redirect to /admin (dashboard)\n    else Credentials invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\" (generic, never reveal which field)\n    end\n```\n\n**Requirements:**\n\n- The login form collects `email` and `password`.\n- On successful login, the session MUST be regenerated to prevent session fixation attacks.\n- On failed login, only the generic message \"Invalid credentials\" is shown. The system must not indicate whether the email or password was wrong.\n- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.\n- After login: redirect to `/admin` (the dashboard).\n\n#### Remember Me\n\n- An optional \"Remember me\" checkbox on the login form.\n- When enabled, Laravel sets a long-lived remember token cookie.\n- The `remember_token` column on the `users` table stores the hashed token.\n- Cookie name follows Laravel default convention: `remember_web_{hash}`.\n\n#### Admin Password Reset Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant App as Application\n    participant DB as Database\n    participant Mail as Mail Service\n\n    B->>App: GET /admin/forgot-password\n    App-->>B: Render forgot-password form\n    B->>App: POST /admin/forgot-password (email)\n    App->>DB: Look up user by email\n    alt User exists\n        App->>DB: Store reset token in password_reset_tokens table\n        App->>Mail: Send reset link email\n    end\n    App-->>B: \"If that email exists, we sent a reset link.\" (generic)\n    B->>App: GET /admin/reset-password/{token}\n    App-->>B: Render reset-password form\n    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)\n    App->>DB: Validate token (exists, not expired, matches email)\n    alt Valid token\n        App->>DB: Update user password (bcrypt hash)\n        App->>DB: Delete used token\n        App-->>B: Redirect to /admin/login with success flash\n    else Invalid/expired token\n        App-->>B: Error message\n    end\n```\n\n**Requirements:**\n\n- Uses Laravel's built-in password broker (`users` broker).\n- Reset tokens stored in the `password_reset_tokens` table.","numLines":100,"startLine":1,"totalLines":1170}},"sourceToolAssistantUUID":"e51061fc-57c7-4ee6-af44-1dcb9687f20e"}
{"parentUuid":"1c323b29-87d0-4bf1-8ddb-3da02ed113f7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TLESgXeWs9PbF6Gu1mceW5","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"6b43893b-9f2f-497c-9acc-0ec9f7c3ebe8","timestamp":"2026-02-11T14:47:50.460Z"}
{"parentUuid":"6b43893b-9f2f-497c-9acc-0ec9f7c3ebe8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01TLESgXeWs9PbF6Gu1mceW5","toolUseID":"toolu_01TLESgXeWs9PbF6Gu1mceW5","timestamp":"2026-02-11T14:47:50.463Z","uuid":"50cfd33c-b678-4259-a1a1-f02fc0ccdef4"}
{"parentUuid":"6b43893b-9f2f-497c-9acc-0ec9f7c3ebe8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TLESgXeWs9PbF6Gu1mceW5","type":"tool_result","content":"     1→# 07 - Seeders and Test Data Specification\n     2→\n     3→> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n     4→\n     5→---\n     6→\n     7→## 1. DatabaseSeeder Orchestration\n     8→\n     9→The main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n    10→\n    11→**Execution order:**\n    12→\n    13→| Step | Seeder Class | Depends On | Purpose |\n    14→|------|-------------|------------|---------|\n    15→| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n    16→| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n    17→| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n    18→| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n    19→| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n    20→| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n    21→| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n    22→| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n    23→| 9 | CollectionSeeder | Stores | Creates product collections |\n    24→| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n    25→| 11 | DiscountSeeder | Stores | Creates discount codes |\n    26→| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n    27→| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n    28→| 14 | ThemeSeeder | Stores | Creates themes with settings |\n    29→| 15 | PageSeeder | Stores | Creates content pages |\n    30→| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n    31→| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n    32→| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n    33→\n    34→**Dependency diagram (Mermaid):**\n    35→\n    36→```mermaid\n    37→graph TD\n    38→    A[OrganizationSeeder] --> B[StoreSeeder]\n    39→    B --> C[StoreDomainSeeder]\n    40→    B --> F[StoreSettingsSeeder]\n    41→    B --> G[TaxSettingsSeeder]\n    42→    B --> H[ShippingSeeder]\n    43→    B --> I[CollectionSeeder]\n    44→    D[UserSeeder] --> E[StoreUserSeeder]\n    45→    B --> E\n    46→    B --> J[ProductSeeder]\n    47→    I --> J\n    48→    B --> K[DiscountSeeder]\n    49→    B --> L[CustomerSeeder]\n    50→    J --> M[OrderSeeder]\n    51→    L --> M\n    52→    K --> M\n    53→    B --> N[ThemeSeeder]\n    54→    B --> O[PageSeeder]\n    55→    I --> P[NavigationSeeder]\n    56→    O --> P\n    57→    B --> Q[AnalyticsSeeder]\n    58→    L --> Q\n    59→    B --> R[SearchSettingsSeeder]\n    60→```\n    61→\n    62→**Running seeders:**\n    63→- All seeders: `php artisan db:seed`\n    64→- Single seeder: `php artisan db:seed --class=ProductSeeder`\n    65→- Fresh database with seed data: `php artisan migrate:fresh --seed`\n    66→\n    67→---\n    68→\n    69→## 2. Factory Definitions\n    70→\n    71→Each factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n    72→\n    73→---\n    74→\n    75→### 2.1 OrganizationFactory\n    76→\n    77→**Model:** Organization\n    78→\n    79→**Default definition:**\n    80→\n    81→| Field | Generation Rule |\n    82→|-------|----------------|\n    83→| name | Faker company name |\n    84→| billing_email | Faker company email |\n    85→\n    86→**States:** None.\n    87→\n    88→---\n    89→\n    90→### 2.2 StoreFactory\n    91→\n    92→**Model:** Store\n    93→\n    94→**Default definition:**\n    95→\n    96→| Field | Generation Rule |\n    97→|-------|----------------|\n    98→| organization_id | Creates a new Organization via its factory |\n    99→| name | Faker company name + \" Store\" suffix |\n   100→| handle | Faker unique slug (2 words) |"}]},"uuid":"dd55a7f6-db1e-422d-9da4-2c8535a131e9","timestamp":"2026-02-11T14:47:50.462Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |","numLines":100,"startLine":1,"totalLines":2215}},"sourceToolAssistantUUID":"6b43893b-9f2f-497c-9acc-0ec9f7c3ebe8"}
{"parentUuid":"dd55a7f6-db1e-422d-9da4-2c8535a131e9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FttEzez5heN74ooRFAqZp9","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"98d5466c-f100-4720-961a-397662a51008","timestamp":"2026-02-11T14:47:51.088Z"}
{"parentUuid":"98d5466c-f100-4720-961a-397662a51008","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01FttEzez5heN74ooRFAqZp9","toolUseID":"toolu_01FttEzez5heN74ooRFAqZp9","timestamp":"2026-02-11T14:47:51.091Z","uuid":"2179e2d1-6fbd-47c5-93a8-9132decfaa78"}
{"parentUuid":"98d5466c-f100-4720-961a-397662a51008","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FttEzez5heN74ooRFAqZp9","type":"tool_result","content":"     1→# 08 - Playwright E2E Test Plan\n     2→\n     3→> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n     4→\n     5→---\n     6→\n     7→## Test Suite Summary\n     8→\n     9→| Suite | File | Tests | Purpose | Dependencies |\n    10→|-------|------|:-----:|---------|--------------|\n    11→| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n    12→| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n    13→| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n    14→| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |\n    15→| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n    16→| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n    17→| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n    18→| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n    19→| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |\n    20→| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n    21→| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n    22→| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n    23→| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n    24→| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n    25→| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n    26→| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n    27→| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n    28→| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n    29→| **Total** | **18 files** | **134** | | |\n    30→\n    31→---\n    32→\n    33→## Test Infrastructure Requirements\n    34→\n    35→### Framework\n    36→\n    37→- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n    38→- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n    39→- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n    40→- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n    41→\n    42→### Pest v4 Browser Testing API Reference\n    43→\n    44→The coding agent should use these Pest v4 browser methods:\n    45→\n    46→| Method | Purpose |\n    47→|--------|---------|\n    48→| `visit(url)` | Open a real browser and navigate to the given URL |\n    49→| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n    50→| `fill(field, value)` | Type a value into a named form field |\n    51→| `click(text)` | Click a visible element matching the given text |\n    52→| `assertSee(text)` | Assert the page contains the given visible text |\n    53→| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n    54→| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n    55→\n    56→Additionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n    57→\n    58→### Environment Configuration\n    59→\n    60→The `.env.testing` file must contain the following keys:\n    61→\n    62→| Key | Value | Purpose |\n    63→|-----|-------|---------|\n    64→| `APP_ENV` | `testing` | Laravel environment |\n    65→| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n    66→| `DB_CONNECTION` | `sqlite` | Database driver |\n    67→| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n    68→| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n    69→| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n    70→| `MAIL_MAILER` | `array` | Trap emails in memory |\n    71→| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n    72→\n    73→### Prerequisites Before Running\n    74→\n    75→1. Database seeded with `php artisan migrate:fresh --seed`\n    76→2. Laravel Herd serving `acme-fashion.test`\n    77→3. Playwright browsers installed via `npx playwright install`\n    78→4. Frontend assets built via `npm run build`\n    79→\n    80→---\n    81→\n    82→## Seeded Data Dependency Table\n    83→\n    84→All tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n    85→\n    86→Each browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n    87→\n    88→| Entity | Key Details | Used By Suites |\n    89→|--------|-------------|----------------|\n    90→| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n    91→| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n    92→| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n    93→| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n    94→| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n    95→| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n    96→| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n    97→| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n    98→| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n    99→| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n   100→| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |"}]},"uuid":"503a8288-88a2-41e6-98f0-9f6e4fbe2985","timestamp":"2026-02-11T14:47:51.090Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **134** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |","numLines":100,"startLine":1,"totalLines":2977}},"sourceToolAssistantUUID":"98d5466c-f100-4720-961a-397662a51008"}
{"parentUuid":"503a8288-88a2-41e6-98f0-9f6e4fbe2985","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PZPp4qSuayA2X51XkELHuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014Mtm7BYuUQLe2xCMPNMzjk","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":27500,"cache_read_input_tokens":42574,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27500},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TusmWbo1ax75cnBNQN","type":"assistant","uuid":"f300dba8-0345-4539-85f6-16087e9f80c6","timestamp":"2026-02-11T14:47:51.654Z"}
{"parentUuid":"f300dba8-0345-4539-85f6-16087e9f80c6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_014Mtm7BYuUQLe2xCMPNMzjk","toolUseID":"toolu_014Mtm7BYuUQLe2xCMPNMzjk","timestamp":"2026-02-11T14:47:51.658Z","uuid":"a9c2446a-32fa-49ce-8a07-8417bfb2f56d"}
{"parentUuid":"f300dba8-0345-4539-85f6-16087e9f80c6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014Mtm7BYuUQLe2xCMPNMzjk","type":"tool_result","content":"     1→# 09 - Implementation Roadmap and Pest Test Specification\n     2→\n     3→> Shopify-clone 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 | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\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)"}]},"uuid":"9ffce5e1-0513-4999-af14-eaef1a1081cb","timestamp":"2026-02-11T14:47:51.657Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| Payments | Stripe + PayPal | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\n| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)","numLines":100,"startLine":1,"totalLines":2027}},"sourceToolAssistantUUID":"f300dba8-0345-4539-85f6-16087e9f80c6"}
{"parentUuid":"9ffce5e1-0513-4999-af14-eaef1a1081cb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014a8tFEvKCJPQYPi29hwhES","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a good overview of all the spec files. Let me also quickly read the remaining portions of the larger files to get a complete picture, particularly the roadmap phases. Let me also check the check files.","signature":"Ev0CCkYICxgCKkCFcf9F97GK1r9LgkTGUNARqaMOqDKcv7/SBzzyF3HerfcrZ+QupKKmDDFnZTKaIGh6p0BhDMulLpsDVbO94ADlEgwFGud1BvWM7h4B5KcaDETBvoorCHT7bu05fiIwnCogRl33sWc6wasVpWKRCmevUmZhjT+QNJYP1rmdUdVvz7ZyFyDX/8K+tuR43bu4KuQBqV0eG6YUwe+k9SFjPIZ7xGRp8FcNIeftiM+NKGvdNR6McmzYKkWUDaw66GSRHW+lP9WgVB2HekCysA5bF0mmoe5KXay5PBYeybUrJPG42cgIJOg+BqdURMDafA4ahH81dmfCSdQnDAFVVYpM3/XG300V54KwFMz/gD9JtY45k9yc7xis/9ICgEMnJ3j1CrekglPt7kD8WVct2k7kv+J9Rp3RLH6yzzZqJ57olbAuifGVEnkLXeXv+rooSaRkXN+t3h74Xxmgdyh8U1j17AatZjIBKJymGTTfXr1aDN52guXW2uuqGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":13745,"cache_read_input_tokens":70074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":13745},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TvbffwvGbtTn1zY9gR","type":"assistant","uuid":"a8965ab6-be8a-46e4-ba89-b5c9c95ef0b9","timestamp":"2026-02-11T14:47:56.153Z"}
{"parentUuid":"a8965ab6-be8a-46e4-ba89-b5c9c95ef0b9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014a8tFEvKCJPQYPi29hwhES","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FvnbUb8bywiKWqGmk65Gmi","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":100,"limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":13745,"cache_read_input_tokens":70074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":13745},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2TvbffwvGbtTn1zY9gR","type":"assistant","uuid":"26526b8d-63a3-4bac-9638-e32e787858d1","timestamp":"2026-02-11T14:47:57.508Z"}
{"parentUuid":"26526b8d-63a3-4bac-9638-e32e787858d1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01FvnbUb8bywiKWqGmk65Gmi","toolUseID":"toolu_01FvnbUb8bywiKWqGmk65Gmi","timestamp":"2026-02-11T14:47:57.512Z","uuid":"578f09d4-90d3-4615-95c4-63d0a7f923ab"}
{"parentUuid":"26526b8d-63a3-4bac-9638-e32e787858d1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FvnbUb8bywiKWqGmk65Gmi","type":"tool_result","content":"   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 |"}]},"uuid":"b494298f-140b-4c12-a3d0-4fde66316f96","timestamp":"2026-02-11T14:47:57.511Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |","numLines":100,"startLine":100,"totalLines":2027}},"sourceToolAssistantUUID":"26526b8d-63a3-4bac-9638-e32e787858d1"}
{"parentUuid":"b494298f-140b-4c12-a3d0-4fde66316f96","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WsQtM26nuHBJzA1wLmAhxU","type":"message","role":"assistant","content":[{"type":"text","text":"Here is a summary of the specs:\n\n## Specs Summary\n\nThe specs describe a **Shopify-clone e-commerce platform** built with Laravel 12, Livewire v4, Flux UI Free, Tailwind CSS v4, and SQLite. It is a multi-tenant, multi-store system.\n\n### 01 - Database Schema\nDefines **46 application tables** (55 total including Laravel framework tables) organized across 8 epics:\n- **Foundation & Tenancy:** Organizations, stores, domains, users, store-user pivot (roles), store settings\n- **Catalog:** Products with options/variants, inventory tracking, collections, media\n- **Themes & Storefront:** Themes with files/settings, CMS pages, navigation menus\n- **Search:** Search settings (synonyms, stop words) and query logging\n- **Cart & Checkout:** Carts, cart lines, checkouts (multi-step), shipping zones/rates, tax settings, discounts\n- **Orders & Payments:** Customers, addresses, orders, order lines, payments (Stripe/PayPal), refunds, fulfillments\n- **Analytics:** Raw event stream + daily aggregates\n- **Apps & Webhooks:** App registry, installations, OAuth clients/tokens, webhook subscriptions/deliveries\n\nAll monetary values stored as integers (cents). SQLite with WAL mode, foreign keys enforced.\n\n### 02 - API & Routes\nDefines all routes: admin auth, admin CRUD pages (Livewire full-page components), storefront pages (Blade + Livewire), and REST API endpoints for cart/checkout operations. Admin is prefixed `/admin` with auth/store-scoping middleware. Storefront resolves stores by hostname.\n\n### 03 - Admin Panel UI\nFull admin panel spec using Livewire v4 + Flux UI Free. Includes sidebar navigation, dashboard with KPIs, product/collection/order/customer/discount management, settings pages, theme management, analytics, and app/developer sections. All interactions via Livewire actions.\n\n### 04 - Storefront UI\nCustomer-facing storefront with Blade + Livewire + Tailwind v4. Mobile-first, dark mode, WCAG 2.2 AA. Includes: layout shell (header, footer, cart drawer), home page with configurable sections (hero, featured collections/products, newsletter), collection/product/cart/search/checkout pages, customer account area. Prices formatted as `24.99 EUR`.\n\n### 05 - Business Logic\nCore domain logic: multi-tenant store resolution (by hostname for storefront, by session for admin), store scoping via global query scope, RBAC (owner/admin/staff/support), product lifecycle, inventory management (deny vs. continue oversell policies), cart calculations, checkout state machine, discount engine, shipping rate calculation, tax computation, order placement, payment processing, fulfillment tracking, and refund handling.\n\n### 06 - Auth & Security\nThree auth mechanisms: session-based admin auth, session-based customer auth (store-scoped with custom guard), and Sanctum token-based API auth. Includes password reset flows, rate limiting, CSRF protection, input validation, and PCI-compliant payment data handling (encrypted storage).\n\n### 07 - Seeders & Test Data\n18 seeder classes run in dependency order to populate realistic demo data: organizations, stores, domains, users (admin/staff), products (20 with variants/options/inventory), collections, discounts, customers, orders with payments/fulfillments, themes, pages, navigation, and analytics. Includes full factory definitions for all models.\n\n### 08 - Playwright E2E Test Plan\n**134 browser tests** across 18 test files using Pest v4 browser testing (Playwright). Covers: smoke tests, admin auth, product/order/discount/collection/customer/page management, storefront browsing, cart flow, checkout flow, customer accounts, inventory enforcement, tenant isolation, responsive/mobile testing, and accessibility (WCAG).\n\n### 09 - Implementation Roadmap\n12 sequential phases: Foundation (migrations, models, middleware, auth) -> Catalog -> Themes/Storefront Layout -> Cart/Checkout/Discounts/Shipping/Taxes -> Payments/Orders/Fulfillment -> Customer Accounts -> Admin Panel -> Search -> Analytics -> Apps/Webhooks -> Polish -> Full Test Suite. Includes a comprehensive Pest test specification with expected test counts per phase."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1933,"cache_read_input_tokens":83819,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1933},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Tw1xfXesVxRU8hkKry","type":"assistant","uuid":"f26d32b6-0a0c-4654-ac8b-2bf7449ea04b","timestamp":"2026-02-11T14:48:24.094Z"}
{"parentUuid":"f26d32b6-0a0c-4654-ac8b-2bf7449ea04b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"Stop","hookName":"Stop","command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"},"parentToolUseID":"5b98f2c5-e688-4e41-81f0-84d3f9d3676b","toolUseID":"5b98f2c5-e688-4e41-81f0-84d3f9d3676b","timestamp":"2026-02-11T14:48:24.134Z","uuid":"88a7a092-7cb7-499e-adb7-7ed41640d36c"}
{"parentUuid":"88a7a092-7cb7-499e-adb7-7ed41640d36c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-02-11T14:48:24.146Z","uuid":"6cdbdccd-0a4d-4524-a790-cfb408c461b8","toolUseID":"5b98f2c5-e688-4e41-81f0-84d3f9d3676b"}
{"parentUuid":"6cdbdccd-0a4d-4524-a790-cfb408c461b8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"turn_duration","durationMs":53195,"timestamp":"2026-02-11T14:48:24.147Z","uuid":"1e19e45b-76a9-470e-b02c-d65957206085","isMeta":false}
{"type":"file-history-snapshot","messageId":"7f5984e7-8bf9-46ea-a542-d5a9f90114de","snapshot":{"messageId":"7f5984e7-8bf9-46ea-a542-d5a9f90114de","trackedFileBackups":{},"timestamp":"2026-02-11T14:49:34.699Z"},"isSnapshotUpdate":false}
{"parentUuid":"1e19e45b-76a9-470e-b02c-d65957206085","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"Describe the functionality as user stories (not epic by epic; the big picture)"},"uuid":"7f5984e7-8bf9-46ea-a542-d5a9f90114de","timestamp":"2026-02-11T14:49:34.698Z","thinkingMetadata":{"maxThinkingTokens":31999},"permissionMode":"acceptEdits"}
{"parentUuid":"7f5984e7-8bf9-46ea-a542-d5a9f90114de","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01HeLEXpFfMUrSu1kyGHVC6k","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":20,"cache_read_input_tokens":86768,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20},"output_tokens":14,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2U4BKvSarazoG84fNg3","type":"assistant","uuid":"3489aeca-c421-4777-9241-2057c51809ce","timestamp":"2026-02-11T14:49:36.785Z"}
{"parentUuid":"3489aeca-c421-4777-9241-2057c51809ce","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01HeLEXpFfMUrSu1kyGHVC6k","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to describe the functionality as user stories from a big-picture perspective, not organized by epic/technical grouping but by user-facing functionality. Let me synthesize all the specs into coherent user stories organized by the different actors/personas.","signature":"EroDCkYICxgCKkBxULxz0k1SrymOQqPRe8Nw3e2xCINOFtIfKzQi2KUWFJ0E+tihRR+fMb6MjY8x71LJjaK293HcVqW2dqp0TMhDEgwQlBgTIp3eS+oO/C0aDOKds5nNYSY5ZNoLByIwN4081XcUwr4BIdogrVNoUczvvJicMT9o5f37qMc3aSnLWptGGItnbfrTmRmkqvbzKqEC6zsrrAk9Z6AUbC4PF73zE8dKlTjekzC9q4eZfheQgnytlwCyvQxPSDl5Mp69BXgA8j2mC1dEMBadT8rOmAUbbmb6a0TU2wL/dWvkq0UDRXHPoo2h13xw0vnaleB57rMF11ui+xgCPTDyo++oq5gJI0C2W8JO3+951t23EDIm9MDBFft2krn1kjs14ZEUM/GTEXOb9Gdp2CBMFcjp+Dra8x/AXOsiO5IxsOx8n8DKyRPYe2lJffNvfmlb30idQ97XBRXj/b1qbHehx4PYOfF9V8gsAJDGZg5r08jIIAxxPeo6OtKKw5Fe3cES2OJfyUm1Wtk23Bzrg5O9f4hwrabRiML6GloMbpUNYCNQsX00ec7MQA0+iIIJFJs20r8pm7rNJBgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":20,"cache_read_input_tokens":86768,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20},"output_tokens":14,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2U4BKvSarazoG84fNg3","type":"assistant","uuid":"b8523a12-b553-4f1b-984f-d75b9330c97c","timestamp":"2026-02-11T14:49:38.087Z"}
{"parentUuid":"b8523a12-b553-4f1b-984f-d75b9330c97c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01HeLEXpFfMUrSu1kyGHVC6k","type":"message","role":"assistant","content":[{"type":"text","text":"## User Stories\n\n### Store Owner / Organization\n\n- As a store owner, I can create an organization and set up one or more stores under it, each with its own name, currency, locale, and timezone.\n- As a store owner, I can attach custom domains to my store so customers reach my storefront via my own hostname.\n- As a store owner, I can invite staff members and assign them roles (admin, staff, support) that control what they can see and do in the admin panel.\n- As a store owner, I can suspend a store, which takes the storefront offline (503) while still allowing admin access for configuration.\n\n### Admin / Staff (Product & Catalog)\n\n- As an admin, I can create products with a title, description, vendor, type, tags, and rich-text content.\n- As an admin, I can define option dimensions on a product (e.g. Size, Color) and their values (S/M/L, Black/White), which generate purchasable variants.\n- As an admin, I can set per-variant pricing (including compare-at/sale prices), SKUs, barcodes, weight, and shipping requirements.\n- As an admin, I can upload images and videos to a product and reorder them.\n- As an admin, I can manage product lifecycle: draft (invisible to customers), active (published), or archived (hidden but preserved).\n- As an admin, I can organize products into collections - either curated manually or (later) rule-based - and control the display order.\n- As an admin, I can track inventory per variant, see on-hand vs. reserved quantities, and choose whether a variant can be oversold (\"continue\") or blocks purchases at zero stock (\"deny\").\n- As an admin, I can search, filter, and bulk-act on products from a list view.\n\n### Admin / Staff (Orders & Fulfillment)\n\n- As an admin, I can view all orders filtered by status (pending, paid, fulfilled, cancelled, refunded) and by date range.\n- As an admin, I can open an order to see its full timeline: line items (with snapshots of what was purchased), payment attempts, fulfillment shipments, and refunds.\n- As an admin, I can create a fulfillment for an order by entering a tracking number and carrier, marking specific line items and quantities as shipped.\n- As an admin, I can partially fulfill an order (ship some items now, the rest later).\n- As an admin, I can issue a full or partial refund against a captured payment, which updates the order's financial status accordingly.\n- As a support user, I can view orders read-only but cannot modify them.\n\n### Admin (Discounts)\n\n- As an admin, I can create discount codes that customers enter at checkout, or automatic discounts that apply without a code.\n- As an admin, I can configure a discount as fixed amount, percentage, or free shipping.\n- As an admin, I can set eligibility rules: minimum purchase amount, applicable products/collections, usage limits, and start/end dates.\n- As an admin, I can see how many times a discount has been redeemed and disable it when needed.\n\n### Admin (Shipping & Taxes)\n\n- As an admin, I can define shipping zones by country and region, then create rate options within each zone (flat rate, weight-based, price-based, or carrier-calculated).\n- As an admin, I can configure tax settings: manual mode with fixed rates, or provider mode (Stripe Tax), and choose whether prices are tax-inclusive.\n\n### Admin (Content & Themes)\n\n- As an admin, I can create and edit static CMS pages (About Us, Contact, FAQ) with rich-text content and publish/draft/archive controls.\n- As an admin, I can build navigation menus (main menu, footer) with links pointing to pages, collections, products, or arbitrary URLs.\n- As an admin, I can manage themes: customize settings (colors, typography, announcement bar, section order), preview, publish, duplicate, or delete a theme.\n\n### Admin (Analytics & Settings)\n\n- As an admin, I can view a dashboard with KPI tiles (revenue, orders, average order value, conversion rate) and charts over configurable date ranges.\n- As an admin, I can see a conversion funnel from visits through add-to-cart, checkout started, to checkout completed.\n- As an admin, I can configure general store settings, domain mappings, localization, checkout behavior, notification preferences, and API tokens from a unified settings area.\n\n### Admin (Apps & Developers)\n\n- As an admin, I can install third-party apps on my store, granting them specific permission scopes.\n- As an admin, I can register webhook subscriptions so external systems receive real-time notifications when events occur (order placed, product updated, etc.).\n- As a developer, I can authenticate via OAuth2 and use REST API endpoints to programmatically manage store resources.\n\n### Customer (Browsing & Shopping)\n\n- As a customer, I can visit a store's homepage and see a hero banner, featured collections, and featured products.\n- As a customer, I can browse a collection page with products displayed in a grid, filter by availability, price range, or product type, and sort by price, name, or date.\n- As a customer, I can view a product detail page showing images (with gallery), description, variant selector (size/color dropdowns or swatches), pricing (with sale badges), and stock status.\n- As a customer, I see \"Sold out\" and a disabled add-to-cart button when a variant has zero stock and the store denies overselling.\n- As a customer, I see \"Available on backorder\" and can still purchase when the store allows overselling.\n- As a customer, I can search for products by keyword and see relevant results with highlighted matches.\n- As a customer, I can read static content pages (About, Contact, etc.) linked from the navigation menu.\n\n### Customer (Cart)\n\n- As a customer, I can add a product variant to my cart and see it appear in a slide-out cart drawer without a full page reload.\n- As a customer, I can update quantities or remove items from my cart, with line totals and the cart total recalculating in real time.\n- As a customer, I can enter a discount code in the cart and see the discount applied (or an error if invalid/expired/minimum not met).\n- As a customer, my cart persists across page navigations and (if logged in) is tied to my account.\n\n### Customer (Checkout)\n\n- As a customer, I can proceed to checkout where I enter my contact email, shipping address, and billing address in a guided multi-step flow.\n- As a customer, I can select a shipping method from the rates available for my address's zone, with the cost reflected in my order total.\n- As a customer, I see a breakdown of subtotal, discount, shipping, tax, and grand total before confirming payment.\n- As a customer, I can pay via Stripe (credit card) with the transaction processed in test mode.\n- As a customer, after successful payment I see an order confirmation page with my order number and summary.\n- As a guest, I can complete the entire checkout without creating an account.\n\n### Customer (Account)\n\n- As a customer, I can register for an account with my email and password, scoped to the store I am visiting.\n- As a customer, I can log in and out of my account.\n- As a customer, I can reset my password via an emailed link.\n- As a customer, I can view my order history, with each order showing status, items, and tracking information.\n- As a customer, I can manage saved addresses (add, edit, delete, set a default).\n\n### Platform (Cross-Cutting)\n\n- As the platform, I enforce tenant isolation so that Store A's products, customers, orders, and all other data are never visible or accessible from Store B.\n- As the platform, I track analytics events (page views, product views, add-to-cart, checkout steps) per store and aggregate them into daily summaries.\n- As the platform, I deliver webhooks to subscribed endpoints with HMAC-signed payloads, retrying on failure.\n- As the platform, I serve the storefront responsively across mobile, tablet, and desktop viewports, with dark mode support and WCAG 2.2 AA accessibility compliance."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":20,"cache_read_input_tokens":86768,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20},"output_tokens":14,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2U4BKvSarazoG84fNg3","type":"assistant","uuid":"1ead8b82-687e-496d-98ca-6c796aea539e","timestamp":"2026-02-11T14:50:22.397Z"}
{"parentUuid":"1ead8b82-687e-496d-98ca-6c796aea539e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"Stop","hookName":"Stop","command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"},"parentToolUseID":"b46cc238-812e-488c-8b2b-0a35ccb2387f","toolUseID":"b46cc238-812e-488c-8b2b-0a35ccb2387f","timestamp":"2026-02-11T14:50:22.442Z","uuid":"d61fc8a9-641e-475f-a427-97bbe0d5d6fa"}
{"parentUuid":"d61fc8a9-641e-475f-a427-97bbe0d5d6fa","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-02-11T14:50:22.453Z","uuid":"dad07877-0866-453b-90bd-b3506cb46529","toolUseID":"b46cc238-812e-488c-8b2b-0a35ccb2387f"}
{"parentUuid":"dad07877-0866-453b-90bd-b3506cb46529","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"turn_duration","durationMs":47754,"timestamp":"2026-02-11T14:50:22.455Z","uuid":"d3a4db5b-d0a1-4c4f-9b97-658cf8cb20c9","isMeta":false}
{"type":"file-history-snapshot","messageId":"6c018fa5-ddb2-40f0-8b26-e7f849b75f12","snapshot":{"messageId":"6c018fa5-ddb2-40f0-8b26-e7f849b75f12","trackedFileBackups":{},"timestamp":"2026-02-11T15:01:02.324Z"},"isSnapshotUpdate":false}
{"parentUuid":"d3a4db5b-d0a1-4c4f-9b97-658cf8cb20c9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"Do we cover a seed data for a demoshop? If yes, what kind of products and how many? Are all capabilities of our shop covered by the demo data?\n\nAnd PSP is dummy, right? What payment methods and what shopiment methods?"},"uuid":"6c018fa5-ddb2-40f0-8b26-e7f849b75f12","timestamp":"2026-02-11T15:01:02.323Z","thinkingMetadata":{"maxThinkingTokens":31999},"permissionMode":"acceptEdits"}
{"parentUuid":"6c018fa5-ddb2-40f0-8b26-e7f849b75f12","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011QbbuRhosZE3JcdxDdRSFR","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":56,"cache_read_input_tokens":88736,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":56},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UvsJ8dL6MHM4kJjCSd","type":"assistant","uuid":"7f0d754c-d610-4ed4-b496-56442efd725b","timestamp":"2026-02-11T15:01:04.302Z"}
{"parentUuid":"7f0d754c-d610-4ed4-b496-56442efd725b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011QbbuRhosZE3JcdxDdRSFR","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking several specific questions about the seed data and payment/shipping configuration. Let me read the relevant spec files more thoroughly to answer these questions.\n\n1. Is there seed data for a demo shop? What products? How many?\n2. Are all capabilities covered by the demo data?\n3. Is PSP (Payment Service Provider) dummy?\n4. What payment methods?\n5. What shipment methods?\n\nI already read the beginning of spec 07. Let me read more of it, and also check spec 05 for payment/shipping details.","signature":"EqIFCkYICxgCKkC2ApcwuX/jQpG1EBQzf6P8C3a4wVI6ZaFdbqUE+YExJu3HbeQ+7KU87ysWMM4cn99AUm61Pie+ca4fOQsYXKhxEgyEwrOQOM33WQQ5dikaDGyK0bzHFI9F5MN1GiIwl5EoPKUCbLbU8/7OTKlUkWxsXZC/ARbW11sWAPcYCwYYYr5AI0vVlz4dpgYudWD2KokEL3PwbPy9SQU5AkN1vEG4IW9+l4gL8khGP9CJPvuZgG1LBgsFd2/5xqV+zy8I+sD66PvdFoFt99DT+e6UE9N8iigQ3rTjCiwKL5hXJsnvwIjO7+CwjCm8YXRy9UElIz4wjfpdnEHhG7Pctr4mshe7p8KPJm+mnjp5dF5cFj8Bp7Q04qWlz6bIgZOZ+D6kFysq7HxfZddLgV21P/l7vvbg91LdRcu2AZs/QRjtFlOjaKgnnD+vmoVxYi3P2BjlPuXECKREe8m5XAjx58xvIdoy+no6WSNdF6RXBbdt0nxGkpDBkBx7lDMS1JSRJIyjyYX2U+oaZFX6Z8iKfkmJe1hXuGJ+1b/qppO7Vr3JPkkJZ6VHr7dh2EQd1a4rsDjSu1iqw7Qn/8LjVlLzeO1wM/sHXWLwHKp1GaLJoMJ+u2G28G4sTPyC2QXhAJk/MeBSZTUKUtJJyhKO6VoOcRQGJ8Vy/+4U+/81+JAKOFa62LIzQDvtbf2A1SuUriVuNkpI4U2HDs/MGd7B3LAmvGdStbOS9bVvUk5qBWyhpiSPK8odBswG0pasyqvtWPlxMRTzl8/YqiyQqit/LjtvgfyzQj57uD8m4NqZqSxF9mqB4aLLt8wUJQp5BIDNifE3PCzXBvMkvQX6qTKb6/FAbaUA1VMMoVp5z1UZzhl71m0vsWDNas94NpU0u6gUknkYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":56,"cache_read_input_tokens":88736,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":56},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UvsJ8dL6MHM4kJjCSd","type":"assistant","uuid":"fe9a53a6-2b2d-4177-98de-7a719a4e8770","timestamp":"2026-02-11T15:01:07.042Z"}
{"parentUuid":"fe9a53a6-2b2d-4177-98de-7a719a4e8770","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011QbbuRhosZE3JcdxDdRSFR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SXkniJLTnrjwMRnCgzt3bY","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":100,"limit":400}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":56,"cache_read_input_tokens":88736,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":56},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UvsJ8dL6MHM4kJjCSd","type":"assistant","uuid":"ae9c4db0-cb23-4b59-9761-97890d413009","timestamp":"2026-02-11T15:01:08.591Z"}
{"parentUuid":"ae9c4db0-cb23-4b59-9761-97890d413009","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01SXkniJLTnrjwMRnCgzt3bY","toolUseID":"toolu_01SXkniJLTnrjwMRnCgzt3bY","timestamp":"2026-02-11T15:01:08.598Z","uuid":"bd967f36-3e6b-4316-8c3f-b13109b426bc"}
{"parentUuid":"ae9c4db0-cb23-4b59-9761-97890d413009","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SXkniJLTnrjwMRnCgzt3bY","type":"tool_result","content":"   100→| handle | Faker unique slug (2 words) |\n   101→| status | \"active\" |\n   102→| default_currency | \"EUR\" |\n   103→| default_locale | \"en\" |\n   104→| timezone | \"Europe/Berlin\" |\n   105→\n   106→**States:**\n   107→\n   108→| State Name | Overrides |\n   109→|-----------|-----------|\n   110→| `suspended` | status = \"suspended\" |\n   111→\n   112→---\n   113→\n   114→### 2.3 StoreDomainFactory\n   115→\n   116→**Model:** StoreDomain\n   117→\n   118→**Default definition:**\n   119→\n   120→| Field | Generation Rule |\n   121→|-------|----------------|\n   122→| store_id | Creates a new Store via its factory |\n   123→| hostname | Faker unique domain name |\n   124→| type | \"storefront\" |\n   125→| is_primary | 1 |\n   126→| tls_mode | \"managed\" |\n   127→\n   128→**States:**\n   129→\n   130→| State Name | Overrides |\n   131→|-----------|-----------|\n   132→| `admin` | type = \"admin\" |\n   133→| `api` | type = \"api\" |\n   134→| `secondary` | is_primary = 0 |\n   135→\n   136→---\n   137→\n   138→### 2.4 UserFactory\n   139→\n   140→**Model:** User\n   141→\n   142→**Default definition:**\n   143→\n   144→| Field | Generation Rule |\n   145→|-------|----------------|\n   146→| email | Faker unique safe email |\n   147→| password_hash | bcrypt of \"password\" |\n   148→| name | Faker full name |\n   149→| status | \"active\" |\n   150→| last_login_at | Random date within the last 30 days |\n   151→\n   152→**States:**\n   153→\n   154→| State Name | Overrides |\n   155→|-----------|-----------|\n   156→| `disabled` | status = \"disabled\" |\n   157→\n   158→---\n   159→\n   160→### 2.5 ProductFactory\n   161→\n   162→**Model:** Product\n   163→\n   164→**Default definition:**\n   165→\n   166→| Field | Generation Rule |\n   167→|-------|----------------|\n   168→| store_id | Creates a new Store via its factory |\n   169→| title | Faker 3 random words joined |\n   170→| handle | URL-slug of the title |\n   171→| status | \"active\" |\n   172→| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n   173→| vendor | Faker company name |\n   174→| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n   175→| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n   176→| published_at | Current timestamp |\n   177→\n   178→**States:**\n   179→\n   180→| State Name | Overrides |\n   181→|-----------|-----------|\n   182→| `draft` | status = \"draft\", published_at = null |\n   183→| `archived` | status = \"archived\" |\n   184→\n   185→**Relationship hooks:**\n   186→\n   187→| Hook Name | Behavior |\n   188→|-----------|----------|\n   189→| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n   190→| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n   191→\n   192→---\n   193→\n   194→### 2.6 ProductVariantFactory\n   195→\n   196→**Model:** ProductVariant\n   197→\n   198→**Default definition:**\n   199→\n   200→| Field | Generation Rule |\n   201→|-------|----------------|\n   202→| product_id | Creates a new Product via its factory |\n   203→| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n   204→| barcode | Faker EAN-13 |\n   205→| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n   206→| compare_at_amount | null |\n   207→| currency | \"EUR\" |\n   208→| weight_g | Random integer 100-5000 |\n   209→| requires_shipping | 1 |\n   210→| is_default | 0 |\n   211→| position | 0 |\n   212→| status | \"active\" |\n   213→\n   214→**States:**\n   215→\n   216→| State Name | Overrides |\n   217→|-----------|-----------|\n   218→| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n   219→| `digital` | requires_shipping = 0, weight_g = 0 |\n   220→| `default` | is_default = 1 |\n   221→| `archived` | status = \"archived\" |\n   222→\n   223→---\n   224→\n   225→### 2.7 InventoryItemFactory\n   226→\n   227→**Model:** InventoryItem\n   228→\n   229→**Default definition:**\n   230→\n   231→| Field | Generation Rule |\n   232→|-------|----------------|\n   233→| store_id | Creates a new Store via its factory |\n   234→| variant_id | Creates a new ProductVariant via its factory |\n   235→| quantity_on_hand | Random integer 0-100 |\n   236→| quantity_reserved | 0 |\n   237→| policy | \"deny\" |\n   238→\n   239→**States:**\n   240→\n   241→| State Name | Overrides |\n   242→|-----------|-----------|\n   243→| `outOfStock` | quantity_on_hand = 0 |\n   244→| `continuePolicy` | policy = \"continue\" |\n   245→| `lowStock` | quantity_on_hand = random 1-3 |\n   246→\n   247→---\n   248→\n   249→### 2.8 CollectionFactory\n   250→\n   251→**Model:** Collection\n   252→\n   253→**Default definition:**\n   254→\n   255→| Field | Generation Rule |\n   256→|-------|----------------|\n   257→| store_id | Creates a new Store via its factory |\n   258→| title | Faker 2 random words joined |\n   259→| handle | URL-slug of the title |\n   260→| description_html | Faker sentence wrapped in `<p>` tags |\n   261→| type | \"manual\" |\n   262→| status | \"active\" |\n   263→\n   264→**States:**\n   265→\n   266→| State Name | Overrides |\n   267→|-----------|-----------|\n   268→| `draft` | status = \"draft\" |\n   269→| `archived` | status = \"archived\" |\n   270→| `automated` | type = \"automated\" |\n   271→\n   272→---\n   273→\n   274→### 2.9 CartFactory\n   275→\n   276→**Model:** Cart\n   277→\n   278→**Default definition:**\n   279→\n   280→| Field | Generation Rule |\n   281→|-------|----------------|\n   282→| store_id | Creates a new Store via its factory |\n   283→| customer_id | null |\n   284→| currency | \"EUR\" |\n   285→| cart_version | 1 |\n   286→| status | \"active\" |\n   287→\n   288→**States:**\n   289→\n   290→| State Name | Overrides |\n   291→|-----------|-----------|\n   292→| `forCustomer` | customer_id = creates a new Customer via its factory |\n   293→| `converted` | status = \"converted\" |\n   294→| `abandoned` | status = \"abandoned\" |\n   295→\n   296→---\n   297→\n   298→### 2.10 CartLineFactory\n   299→\n   300→**Model:** CartLine\n   301→\n   302→**Default definition:**\n   303→\n   304→| Field | Generation Rule |\n   305→|-------|----------------|\n   306→| cart_id | Creates a new Cart via its factory |\n   307→| variant_id | Creates a new ProductVariant via its factory |\n   308→| quantity | Random 1-5 |\n   309→| unit_price_amount | Random 999-19999 |\n   310→| line_subtotal_amount | Computed: unit_price_amount * quantity |\n   311→| line_discount_amount | 0 |\n   312→| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n   313→\n   314→**States:** None.\n   315→\n   316→---\n   317→\n   318→### 2.11 CheckoutFactory\n   319→\n   320→**Model:** Checkout\n   321→\n   322→**Default definition:**\n   323→\n   324→| Field | Generation Rule |\n   325→|-------|----------------|\n   326→| store_id | Creates a new Store via its factory |\n   327→| cart_id | Creates a new Cart via its factory |\n   328→| customer_id | null |\n   329→| status | \"started\" |\n   330→| email | Faker safe email |\n   331→| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n   332→| billing_address_json | null |\n   333→| shipping_method_id | null |\n   334→| discount_code | null |\n   335→| tax_provider_snapshot_json | null |\n   336→| totals_json | null |\n   337→| expires_at | 24 hours from now |\n   338→\n   339→**States:**\n   340→\n   341→| State Name | Overrides |\n   342→|-----------|-----------|\n   343→| `completed` | status = \"completed\" |\n   344→| `expired` | status = \"expired\", expires_at = 1 hour ago |\n   345→\n   346→---\n   347→\n   348→### 2.12 OrderFactory\n   349→\n   350→**Model:** Order\n   351→\n   352→**Default definition:**\n   353→\n   354→| Field | Generation Rule |\n   355→|-------|----------------|\n   356→| store_id | Creates a new Store via its factory |\n   357→| customer_id | Creates a new Customer via its factory |\n   358→| order_number | \"#\" + Faker unique integer 1001-9999 |\n   359→| status | \"paid\" |\n   360→| financial_status | \"paid\" |\n   361→| fulfillment_status | \"unfulfilled\" |\n   362→| currency | \"EUR\" |\n   363→| subtotal_amount | 4998 |\n   364→| discount_amount | 0 |\n   365→| shipping_amount | 499 |\n   366→| tax_amount | 798 |\n   367→| total_amount | 5497 |\n   368→| email | Faker safe email |\n   369→| billing_address_json | JSON address object with German Faker data |\n   370→| shipping_address_json | JSON address object with German Faker data |\n   371→| placed_at | Current timestamp |\n   372→\n   373→**States:**\n   374→\n   375→| State Name | Overrides |\n   376→|-----------|-----------|\n   377→| `pending` | status = \"pending\", financial_status = \"pending\" |\n   378→| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n   379→| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n   380→| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n   381→| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n   382→\n   383→---\n   384→\n   385→### 2.13 OrderLineFactory\n   386→\n   387→**Model:** OrderLine\n   388→\n   389→**Default definition:**\n   390→\n   391→| Field | Generation Rule |\n   392→|-------|----------------|\n   393→| order_id | Creates a new Order via its factory |\n   394→| product_id | Creates a new Product via its factory |\n   395→| variant_id | Creates a new ProductVariant via its factory |\n   396→| title_snapshot | Faker 3 words |\n   397→| sku_snapshot | Faker pattern \"SKU-####-???\" |\n   398→| quantity | Random 1-3 |\n   399→| unit_price_amount | Random 999-19999 |\n   400→| total_amount | Computed: unit_price_amount * quantity |\n   401→| tax_lines_json | Empty JSON array |\n   402→| discount_allocations_json | Empty JSON array |\n   403→\n   404→**States:** None.\n   405→\n   406→---\n   407→\n   408→### 2.14 PaymentFactory\n   409→\n   410→**Model:** Payment\n   411→\n   412→**Default definition:**\n   413→\n   414→| Field | Generation Rule |\n   415→|-------|----------------|\n   416→| order_id | Creates a new Order via its factory |\n   417→| provider | \"stripe\" |\n   418→| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n   419→| status | \"captured\" |\n   420→| amount | Random 999-99999 |\n   421→| currency | \"EUR\" |\n   422→| raw_json_encrypted | null |\n   423→\n   424→**States:**\n   425→\n   426→| State Name | Overrides |\n   427→|-----------|-----------|\n   428→| `requiresAction` | status = \"requires_action\" |\n   429→| `authorized` | status = \"authorized\" |\n   430→| `failed` | status = \"failed\" |\n   431→| `refunded` | status = \"refunded\" |\n   432→\n   433→---\n   434→\n   435→### 2.15 RefundFactory\n   436→\n   437→**Model:** Refund\n   438→\n   439→**Default definition:**\n   440→\n   441→| Field | Generation Rule |\n   442→|-------|----------------|\n   443→| order_id | Creates a new Order via its factory |\n   444→| payment_id | Creates a new Payment via its factory |\n   445→| amount | Random 999-19999 |\n   446→| reason | Faker sentence |\n   447→| status | \"processed\" |\n   448→| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n   449→\n   450→**States:**\n   451→\n   452→| State Name | Overrides |\n   453→|-----------|-----------|\n   454→| `pending` | status = \"pending\", provider_refund_id = null |\n   455→| `failed` | status = \"failed\" |\n   456→\n   457→---\n   458→\n   459→### 2.16 FulfillmentFactory\n   460→\n   461→**Model:** Fulfillment\n   462→\n   463→**Default definition:**\n   464→\n   465→| Field | Generation Rule |\n   466→|-------|----------------|\n   467→| order_id | Creates a new Order via its factory |\n   468→| status | \"shipped\" |\n   469→| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n   470→| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n   471→| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n   472→| shipped_at | Current timestamp |\n   473→\n   474→**States:**\n   475→\n   476→| State Name | Overrides |\n   477→|-----------|-----------|\n   478→| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n   479→| `delivered` | status = \"delivered\" |\n   480→\n   481→---\n   482→\n   483→### 2.16b FulfillmentLineFactory\n   484→\n   485→**Model:** FulfillmentLine\n   486→\n   487→**Default definition:**\n   488→\n   489→| Field | Rule | Notes |\n   490→|-------|------|-------|\n   491→| fulfillment_id | FK | Parent fulfillment |\n   492→| order_line_id | FK | The order line being fulfilled |\n   493→| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n   494→\n   495→**States:** None.\n   496→\n   497→---\n   498→\n   499→### 2.17 CustomerFactory"}]},"uuid":"c80de149-a59a-4d1a-b37e-2e421507fede","timestamp":"2026-02-11T15:01:08.598Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"stripe\" |\n| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `requiresAction` | status = \"requires_action\" |\n| `authorized` | status = \"authorized\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory","numLines":400,"startLine":100,"totalLines":2215}},"sourceToolAssistantUUID":"ae9c4db0-cb23-4b59-9761-97890d413009"}
{"parentUuid":"c80de149-a59a-4d1a-b37e-2e421507fede","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011QbbuRhosZE3JcdxDdRSFR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CyToa291hfnya3ykzErQZu","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":100,"limit":500}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":56,"cache_read_input_tokens":88736,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":56},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UvsJ8dL6MHM4kJjCSd","type":"assistant","uuid":"dc966fca-5142-4ee4-b570-8f954d408a72","timestamp":"2026-02-11T15:01:09.326Z"}
{"parentUuid":"dc966fca-5142-4ee4-b570-8f954d408a72","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01CyToa291hfnya3ykzErQZu","toolUseID":"toolu_01CyToa291hfnya3ykzErQZu","timestamp":"2026-02-11T15:01:09.329Z","uuid":"0bb80418-de5d-4253-97dc-3f06e6c41ee7"}
{"parentUuid":"dc966fca-5142-4ee4-b570-8f954d408a72","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CyToa291hfnya3ykzErQZu","type":"tool_result","content":"   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_pending` | 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_pending`, `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_pending : Payment initiated\n   423→    payment_pending --> completed : Payment confirmed\n   424→    payment_pending --> 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_pending : 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_pending\n   488→\n   489→**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\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→\n   495→**Actions:**\n   496→1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n   497→2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n   498→3. Set `expires_at` on the checkout to current time + 24 hours.\n   499→4. Transition status to `payment_pending`.\n   500→5. Return the payment session data (redirect URL, client secret, etc.).\n   501→\n   502→#### Transition: payment_pending -> completed\n   503→\n   504→**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n   505→\n   506→**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n   507→\n   508→**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), 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, paymentData):\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. Generate next sequential order number for this store\n   520→\n   521→    3. CREATE order record with:\n   522→       - All checkout data (addresses, email, totals snapshot)\n   523→       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n   524→       - placed_at = now\n   525→\n   526→    4. FOR EACH cart line:\n   527→       CREATE order_line with snapshot data:\n   528→       - title_snapshot = product title + variant option labels\n   529→       - sku_snapshot = variant SKU at time of purchase\n   530→       - unit_price_amount, quantity, total_amount\n   531→       - tax_lines_json, discount_allocations_json\n   532→\n   533→    5. CREATE payment record:\n   534→       - Link to order, provider info, provider_payment_id\n   535→       - status = captured, amount = order total\n   536→       - Encrypt raw provider response for PCI compliance\n   537→\n   538→    6. FOR EACH cart line:\n   539→       CALL InventoryService.commit(variant, quantity)\n   540→\n   541→    7. IF checkout has a discount code:\n   542→       INCREMENT discount's usage_count by 1\n   543→\n   544→    8. SET cart status = 'converted'\n   545→\n   546→    9. SET checkout status = 'completed'\n   547→\n   548→    10. DISPATCH OrderCreated event\n   549→\n   550→    COMMIT TRANSACTION\n   551→    RETURN order\n   552→```\n   553→\n   554→#### Transition: Any active state -> expired\n   555→\n   556→**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n   557→\n   558→**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n   559→\n   560→**Actions:**\n   561→1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n   562→2. Transition status to `expired`.\n   563→3. Dispatch `CheckoutExpired` event.\n   564→\n   565→### 6.3 Events\n   566→\n   567→| Event | Dispatched When |\n   568→|---|---|\n   569→| `CheckoutAddressed` | Address saved on checkout |\n   570→| `CheckoutShippingSelected` | Shipping method selected |\n   571→| `CheckoutCompleted` | Payment confirmed and order created |\n   572→| `CheckoutExpired` | Checkout expired due to inactivity |\n   573→\n   574→---\n   575→\n   576→## 7. Discount Engine\n   577→\n   578→### 7.1 Discount Types\n   579→\n   580→**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n   581→\n   582→| Type | Behavior |\n   583→|---|---|\n   584→| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n   585→| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n   586→\n   587→**Stacking rules:**\n   588→- Only one code discount per checkout.\n   589→- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n   590→\n   591→### Discount Status Lifecycle\n   592→\n   593→| Status | Description | Transition |\n   594→|--------|-------------|------------|\n   595→| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n   596→| `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   597→| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n   598→| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n   599→"}]},"uuid":"453ec4fe-51fb-4be1-bae8-4c910c7d34fe","timestamp":"2026-02-11T15:01:09.328Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n\n**Actions:**\n1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n5. Return the payment session data (redirect URL, client secret, etc.).\n\n#### Transition: payment_pending -> completed\n\n**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n\n**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n\n**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. Generate next sequential order number for this store\n\n    3. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    4. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    5. CREATE payment record:\n       - Link to order, provider info, provider_payment_id\n       - status = captured, amount = order total\n       - Encrypt raw provider response for PCI compliance\n\n    6. FOR EACH cart line:\n       CALL InventoryService.commit(variant, quantity)\n\n    7. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    8. SET cart status = 'converted'\n\n    9. SET checkout status = 'completed'\n\n    10. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n","numLines":500,"startLine":100,"totalLines":1583}},"sourceToolAssistantUUID":"dc966fca-5142-4ee4-b570-8f954d408a72"}
{"parentUuid":"453ec4fe-51fb-4be1-bae8-4c910c7d34fe","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011CBN9qRvFkWaGMPMpkNkZz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NTfynT4fGaRtFr4PKmFKgZ","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":500,"limit":500}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11622,"cache_read_input_tokens":88792,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11622},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UwPAUxz5VFeMUPFAPA","type":"assistant","uuid":"4a2f37bf-94cf-4661-9508-00ba42c22d10","timestamp":"2026-02-11T15:01:13.516Z"}
{"parentUuid":"4a2f37bf-94cf-4661-9508-00ba42c22d10","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01NTfynT4fGaRtFr4PKmFKgZ","toolUseID":"toolu_01NTfynT4fGaRtFr4PKmFKgZ","timestamp":"2026-02-11T15:01:13.519Z","uuid":"3c37285e-900e-4733-bb0b-201a8a1f63fc"}
{"parentUuid":"4a2f37bf-94cf-4661-9508-00ba42c22d10","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NTfynT4fGaRtFr4PKmFKgZ","type":"tool_result","content":"   500→\n   501→**Model:** Customer\n   502→\n   503→**Default definition:**\n   504→\n   505→| Field | Generation Rule |\n   506→|-------|----------------|\n   507→| store_id | Creates a new Store via its factory |\n   508→| email | Faker unique safe email |\n   509→| password_hash | bcrypt of \"password\" |\n   510→| name | Faker full name |\n   511→| marketing_opt_in | 30% chance of 1, otherwise 0 |\n   512→\n   513→**States:**\n   514→\n   515→| State Name | Overrides |\n   516→|-----------|-----------|\n   517→| `guest` | password_hash = null |\n   518→| `optedInMarketing` | marketing_opt_in = 1 |\n   519→\n   520→---\n   521→\n   522→### 2.18 CustomerAddressFactory\n   523→\n   524→**Model:** CustomerAddress\n   525→\n   526→**Default definition:**\n   527→\n   528→| Field | Generation Rule |\n   529→|-------|----------------|\n   530→| customer_id | Creates a new Customer via its factory |\n   531→| label | Random from: Home, Work, Other |\n   532→| address_json | JSON object (see Address JSON Structure in Section 6) |\n   533→| is_default | 0 |\n   534→\n   535→**States:**\n   536→\n   537→| State Name | Overrides |\n   538→|-----------|-----------|\n   539→| `default` | is_default = 1 |\n   540→\n   541→---\n   542→\n   543→### 2.19 DiscountFactory\n   544→\n   545→**Model:** Discount\n   546→\n   547→**Default definition:**\n   548→\n   549→| Field | Generation Rule |\n   550→|-------|----------------|\n   551→| store_id | Creates a new Store via its factory |\n   552→| type | \"code\" |\n   553→| code | Faker unique uppercase pattern \"????##\" |\n   554→| value_type | \"percent\" |\n   555→| value_amount | 10 |\n   556→| starts_at | 1 month ago |\n   557→| ends_at | 1 year from now |\n   558→| usage_limit | null |\n   559→| usage_count | 0 |\n   560→| rules_json | Empty JSON object |\n   561→| status | \"active\" |\n   562→\n   563→**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n   564→\n   565→**States:**\n   566→\n   567→| State Name | Overrides |\n   568→|-----------|-----------|\n   569→| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n   570→| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n   571→| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n   572→| `maxedOut` | usage_limit = 5, usage_count = 5 |\n   573→| `automatic` | type = \"automatic\", code = null |\n   574→| `draft` | status = \"draft\" |\n   575→| `disabled` | status = \"disabled\", starts_at = past date |\n   576→\n   577→---\n   578→\n   579→### 2.20 ShippingZoneFactory\n   580→\n   581→**Model:** ShippingZone\n   582→\n   583→**Default definition:**\n   584→\n   585→| Field | Generation Rule |\n   586→|-------|----------------|\n   587→| store_id | Creates a new Store via its factory |\n   588→| name | Faker country name |\n   589→| countries_json | JSON array: [\"DE\"] |\n   590→| regions_json | Empty JSON array |\n   591→\n   592→**States:** None.\n   593→\n   594→---\n   595→\n   596→### 2.21 ShippingRateFactory\n   597→\n   598→**Model:** ShippingRate\n   599→\n   600→**Default definition:**\n   601→\n   602→| Field | Generation Rule |\n   603→|-------|----------------|\n   604→| zone_id | Creates a new ShippingZone via its factory |\n   605→| name | Random from: Standard, Express, Economy |\n   606→| type | \"flat\" |\n   607→| config_json | JSON object with amount = 499 |\n   608→| is_active | 1 |\n   609→\n   610→**States:**\n   611→\n   612→| State Name | Overrides |\n   613→|-----------|-----------|\n   614→| `inactive` | is_active = 0 |\n   615→| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n   616→\n   617→---\n   618→\n   619→### 2.22 ThemeFactory\n   620→\n   621→**Model:** Theme\n   622→\n   623→**Default definition:**\n   624→\n   625→| Field | Generation Rule |\n   626→|-------|----------------|\n   627→| store_id | Creates a new Store via its factory |\n   628→| name | \"Default Theme\" |\n   629→| version | \"1.0.0\" |\n   630→| status | \"published\" |\n   631→| published_at | Current timestamp |\n   632→\n   633→**States:**\n   634→\n   635→| State Name | Overrides |\n   636→|-----------|-----------|\n   637→| `draft` | status = \"draft\", published_at = null |\n   638→\n   639→---\n   640→\n   641→### 2.23 PageFactory\n   642→\n   643→**Model:** Page\n   644→\n   645→**Default definition:**\n   646→\n   647→| Field | Generation Rule |\n   648→|-------|----------------|\n   649→| store_id | Creates a new Store via its factory |\n   650→| title | Faker 3 random words |\n   651→| handle | URL-slug of the title |\n   652→| body_html | Faker heading + 3 paragraphs in HTML tags |\n   653→| status | \"published\" |\n   654→| published_at | Current timestamp |\n   655→\n   656→**States:**\n   657→\n   658→| State Name | Overrides |\n   659→|-----------|-----------|\n   660→| `draft` | status = \"draft\", published_at = null |\n   661→| `archived` | status = \"archived\" |\n   662→\n   663→---\n   664→\n   665→### 2.24 NavigationMenuFactory\n   666→\n   667→**Model:** NavigationMenu\n   668→\n   669→**Default definition:**\n   670→\n   671→| Field | Generation Rule |\n   672→|-------|----------------|\n   673→| store_id | Creates a new Store via its factory |\n   674→| handle | Faker unique slug (2 words) |\n   675→| title | Faker 2 words |\n   676→\n   677→**States:** None.\n   678→\n   679→---\n   680→\n   681→### 2.25 NavigationItemFactory\n   682→\n   683→**Model:** NavigationItem\n   684→\n   685→**Default definition:**\n   686→\n   687→| Field | Generation Rule |\n   688→|-------|----------------|\n   689→| menu_id | Creates a new NavigationMenu via its factory |\n   690→| type | \"link\" |\n   691→| label | Faker 2 words |\n   692→| url | \"/\" |\n   693→| resource_id | null |\n   694→| position | 0 |\n   695→\n   696→**States:**\n   697→\n   698→| State Name | Overrides |\n   699→|-----------|-----------|\n   700→| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n   701→| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n   702→| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n   703→\n   704→---\n   705→\n   706→### 2.26 AnalyticsEventFactory\n   707→\n   708→**Model:** AnalyticsEvent\n   709→\n   710→**Default definition:**\n   711→\n   712→| Field | Generation Rule |\n   713→|-------|----------------|\n   714→| store_id | Creates a new Store via its factory |\n   715→| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n   716→| session_id | Faker UUID |\n   717→| customer_id | null |\n   718→| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n   719→| created_at | Random timestamp within last 7 days |\n   720→\n   721→**States:**\n   722→\n   723→| State Name | Overrides |\n   724→|-----------|-----------|\n   725→| `pageView` | type = \"page_view\" |\n   726→| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n   727→| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n   728→\n   729→---\n   730→\n   731→## 3. Demo Seed Scenario (Exact Data)\n   732→\n   733→All seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n   734→\n   735→---\n   736→\n   737→### 3.1 OrganizationSeeder\n   738→\n   739→Creates one organization.\n   740→\n   741→| Field | Value |\n   742→|-------|-------|\n   743→| name | Acme Corp |\n   744→| billing_email | billing@acme.test |\n   745→\n   746→---\n   747→\n   748→### 3.2 StoreSeeder\n   749→\n   750→Creates two stores under \"Acme Corp\".\n   751→\n   752→**Store 1: Acme Fashion**\n   753→\n   754→| Field | Value |\n   755→|-------|-------|\n   756→| name | Acme Fashion |\n   757→| handle | acme-fashion |\n   758→| status | active |\n   759→| default_currency | EUR |\n   760→| default_locale | en |\n   761→| timezone | Europe/Berlin |\n   762→\n   763→**Store 2: Acme Electronics**\n   764→\n   765→| Field | Value |\n   766→|-------|-------|\n   767→| name | Acme Electronics |\n   768→| handle | acme-electronics |\n   769→| status | active |\n   770→| default_currency | EUR |\n   771→| default_locale | en |\n   772→| timezone | Europe/Berlin |\n   773→\n   774→---\n   775→\n   776→### 3.3 StoreDomainSeeder\n   777→\n   778→**Acme Fashion domains:**\n   779→\n   780→| Hostname | Type | is_primary | tls_mode |\n   781→|----------|------|-----------|----------|\n   782→| acme-fashion.test | storefront | 1 | managed |\n   783→| admin.acme-fashion.test | admin | 0 | managed |\n   784→\n   785→**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n   786→\n   787→**Acme Electronics domains:**\n   788→\n   789→| Hostname | Type | is_primary | tls_mode |\n   790→|----------|------|-----------|----------|\n   791→| acme-electronics.test | storefront | 1 | managed |\n   792→\n   793→---\n   794→\n   795→### 3.4 UserSeeder\n   796→\n   797→| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n   798→|-------|------|----------------------------------------------|--------|---------------|\n   799→| admin@acme.test | Admin User | password | active | now |\n   800→| staff@acme.test | Staff User | password | active | 2 days ago |\n   801→| support@acme.test | Support User | password | active | 1 day ago |\n   802→| manager@acme.test | Store Manager | password | active | 1 day ago |\n   803→| admin2@acme.test | Admin Two | password | active | 1 day ago |\n   804→\n   805→---\n   806→\n   807→### 3.5 StoreUserSeeder\n   808→\n   809→| User Email | Store | Role |\n   810→|------------|-------|------|\n   811→| admin@acme.test | Acme Fashion | owner |\n   812→| staff@acme.test | Acme Fashion | staff |\n   813→| support@acme.test | Acme Fashion | support |\n   814→| manager@acme.test | Acme Fashion | admin |\n   815→| admin2@acme.test | Acme Electronics | owner |\n   816→\n   817→---\n   818→\n   819→### 3.6 StoreSettingsSeeder\n   820→\n   821→**Acme Fashion - settings_json contents:**\n   822→\n   823→| Setting Key | Value |\n   824→|-------------|-------|\n   825→| store_name | Acme Fashion |\n   826→| contact_email | hello@acme-fashion.test |\n   827→| order_number_prefix | # |\n   828→| order_number_start | 1001 |\n   829→\n   830→**Acme Electronics - settings_json contents:**\n   831→\n   832→| Setting Key | Value |\n   833→|-------------|-------|\n   834→| store_name | Acme Electronics |\n   835→| contact_email | hello@acme-electronics.test |\n   836→| order_number_prefix | # |\n   837→| order_number_start | 5001 |\n   838→\n   839→---\n   840→\n   841→### 3.7 TaxSettingsSeeder\n   842→\n   843→**Acme Fashion:**\n   844→\n   845→| Field | Value |\n   846→|-------|-------|\n   847→| mode | manual |\n   848→| provider | none |\n   849→| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n   850→| config_json | default_rate_bps: 1900 |\n   851→\n   852→Tax rate: 19% (1900 basis points) applied to all zones.\n   853→\n   854→**Acme Electronics:** Same as Acme Fashion.\n   855→\n   856→---\n   857→\n   858→### 3.8 ShippingSeeder\n   859→\n   860→**Acme Fashion - Shipping Zones and Rates:**\n   861→\n   862→**Zone 1: \"Domestic\"**\n   863→- countries_json: [\"DE\"]\n   864→- regions_json: []\n   865→\n   866→| Rate Name | Type | config_json (amount in cents) | is_active |\n   867→|-----------|------|-------------------------------|-----------|\n   868→| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n   869→| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n   870→\n   871→**Zone 2: \"EU\"**\n   872→- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n   873→- regions_json: []\n   874→\n   875→| Rate Name | Type | config_json (amount in cents) | is_active |\n   876→|-----------|------|-------------------------------|-----------|\n   877→| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n   878→\n   879→**Zone 3: \"Rest of World\"**\n   880→- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n   881→- regions_json: []\n   882→\n   883→| Rate Name | Type | config_json (amount in cents) | is_active |\n   884→|-----------|------|-------------------------------|-----------|\n   885→| International | flat | amount: 1499 (14.99 EUR) | 1 |\n   886→\n   887→**Acme Electronics - One zone:**\n   888→\n   889→**Zone: \"Germany\"**\n   890→- countries_json: [\"DE\"]\n   891→\n   892→| Rate Name | Type | config_json | is_active |\n   893→|-----------|------|-------------|-----------|\n   894→| Standard | flat | amount: 0 (free) | 1 |\n   895→\n   896→---\n   897→\n   898→### 3.9 CollectionSeeder\n   899→\n   900→**Acme Fashion - 4 collections:**\n   901→\n   902→| # | Title | Handle | Type | Status | description_html |\n   903→|---|-------|--------|------|--------|------------------|\n   904→| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n   905→| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n   906→| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n   907→| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n   908→\n   909→**Acme Electronics - 2 collections:**\n   910→\n   911→| # | Title | Handle | Type | Status |\n   912→|---|-------|--------|------|--------|\n   913→| 1 | Featured | featured | manual | active |\n   914→| 2 | Accessories | accessories | manual | active |\n   915→\n   916→---\n   917→\n   918→### 3.10 ProductSeeder\n   919→\n   920→#### Acme Fashion - 20 Products\n   921→\n   922→For each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n   923→\n   924→---\n   925→\n   926→**Product 1: \"Classic Cotton T-Shirt\"**\n   927→\n   928→| Field | Value |\n   929→|-------|-------|\n   930→| handle | classic-cotton-t-shirt |\n   931→| status | active |\n   932→| vendor | Acme Basics |\n   933→| product_type | T-Shirts |\n   934→| tags | [\"new\", \"popular\"] |\n   935→| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n   936→| published_at | now |\n   937→\n   938→Collections: New Arrivals, T-Shirts\n   939→\n   940→Options and values:\n   941→\n   942→| Option Name | Values (in position order) |\n   943→|-------------|---------------------------|\n   944→| Size | S, M, L, XL |\n   945→| Color | White, Black, Navy |\n   946→\n   947→Variants (12 total - all size x color combinations):\n   948→- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n   949→- First variant (S/White) is marked is_default = 1\n   950→- Position increments 0 through 11\n   951→\n   952→| Per-variant field | Value |\n   953→|-------------------|-------|\n   954→| price_amount | 2499 (24.99 EUR) |\n   955→| compare_at_amount | null |\n   956→| currency | EUR |\n   957→| weight_g | 200 |\n   958→| requires_shipping | 1 |\n   959→\n   960→Inventory per variant: quantity_on_hand = 15, policy = \"deny\"\n   961→\n   962→---\n   963→\n   964→**Product 2: \"Premium Slim Fit Jeans\"**\n   965→\n   966→| Field | Value |\n   967→|-------|-------|\n   968→| handle | premium-slim-fit-jeans |\n   969→| status | active |\n   970→| vendor | Acme Denim |\n   971→| product_type | Pants |\n   972→| tags | [\"new\", \"sale\"] |\n   973→| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n   974→| published_at | now |\n   975→\n   976→Collections: New Arrivals, Pants & Jeans, Sale\n   977→\n   978→Options and values:\n   979→\n   980→| Option Name | Values |\n   981→|-------------|--------|\n   982→| Size | 28, 30, 32, 34, 36 |\n   983→| Color | Blue, Black |\n   984→\n   985→Variants (10 total): First variant (28/Blue) is default.\n   986→\n   987→| Per-variant field | Value |\n   988→|-------------------|-------|\n   989→| price_amount | 7999 (79.99 EUR) |\n   990→| compare_at_amount | 9999 (99.99 EUR - on sale) |\n   991→| weight_g | 800 |\n   992→| requires_shipping | 1 |\n   993→\n   994→Inventory per variant: quantity_on_hand = 8, policy = \"deny\"\n   995→\n   996→---\n   997→\n   998→**Product 3: \"Organic Hoodie\"**\n   999→"}]},"uuid":"bc479378-95e3-4e90-9b68-e6fcd9d76ffd","timestamp":"2026-02-11T15:01:13.518Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n","numLines":500,"startLine":500,"totalLines":2215}},"sourceToolAssistantUUID":"4a2f37bf-94cf-4661-9508-00ba42c22d10"}
{"parentUuid":"bc479378-95e3-4e90-9b68-e6fcd9d76ffd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011CBN9qRvFkWaGMPMpkNkZz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014SPiE9havsVU2WczF6Lp46","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":600,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11622,"cache_read_input_tokens":88792,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11622},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UwPAUxz5VFeMUPFAPA","type":"assistant","uuid":"cd9fd44e-bf4d-43b5-ba0a-292bf3687975","timestamp":"2026-02-11T15:01:14.296Z"}
{"parentUuid":"cd9fd44e-bf4d-43b5-ba0a-292bf3687975","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_014SPiE9havsVU2WczF6Lp46","toolUseID":"toolu_014SPiE9havsVU2WczF6Lp46","timestamp":"2026-02-11T15:01:14.301Z","uuid":"f6d85c64-511c-42ba-a108-5b5612585906"}
{"parentUuid":"cd9fd44e-bf4d-43b5-ba0a-292bf3687975","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014SPiE9havsVU2WczF6Lp46","type":"tool_result","content":"   600→**Allowed transitions:**\n   601→- `draft` -> `active`\n   602→- `active` -> `disabled`\n   603→- `active` -> `expired` (automatic)\n   604→- `disabled` -> `active`\n   605→\n   606→### 7.2 Value Types\n   607→\n   608→**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n   609→\n   610→| Value Type | Calculation | Notes |\n   611→|---|---|---|\n   612→| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n   613→| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n   614→| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n   615→\n   616→### 7.3 Rules\n   617→\n   618→Rules are stored in `discounts.rules_json` with the following structure:\n   619→\n   620→| Rule Key | Type | Description |\n   621→|---|---|---|\n   622→| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n   623→| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n   624→| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n   625→| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n   626→\n   627→**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   628→\n   629→### 7.4 Validation Flow\n   630→\n   631→**Service:** `App\\Services\\DiscountService`\n   632→\n   633→**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n   634→\n   635→```mermaid\n   636→flowchart TD\n   637→    A[Input: discount code + cart] --> B{Code exists in store?\n   638→    Case-insensitive lookup}\n   639→    B -->|No| C[\"Error: discount_not_found\"]\n   640→    B -->|Yes| D{Status = active?}\n   641→    D -->|No| E[\"Error: discount_expired\"]\n   642→    D -->|Yes| F{starts_at <= now?}\n   643→    F -->|No| G[\"Error: discount_not_yet_active\"]\n   644→    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n   645→    H -->|No| I[\"Error: discount_expired\"]\n   646→    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n   647→    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n   648→    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n   649→    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n   650→    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n   651→    N -->|Yes| O[\"Error: discount_not_applicable\"]\n   652→    N -->|No| P[\"Valid - return discount\"]\n   653→```\n   654→\n   655→### 7.5 Error Codes\n   656→\n   657→| Code | Description |\n   658→|---|---|\n   659→| `discount_not_found` | Code does not exist in this store |\n   660→| `discount_expired` | Past `ends_at` date or status is not active |\n   661→| `discount_not_yet_active` | Before `starts_at` date |\n   662→| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n   663→| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n   664→| `discount_not_applicable` | No qualifying products in cart |\n   665→\n   666→### 7.6 Discount Allocation\n   667→\n   668→For `percent` and `fixed` discounts with applicable product restrictions:\n   669→\n   670→```\n   671→PROCEDURE allocateDiscount(discount, cartLines):\n   672→    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n   673→                      OR product belongs to applicable_collection_ids\n   674→    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n   675→\n   676→    IF discount.value_type = 'percent':\n   677→        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n   678→    ELSE IF discount.value_type = 'fixed':\n   679→        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n   680→\n   681→    remainingDiscount = totalDiscount\n   682→\n   683→    FOR EACH qualifyingLine (except last):\n   684→        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n   685→        remainingDiscount -= lineDiscount\n   686→        SET line.line_discount_amount = lineDiscount\n   687→\n   688→    // Last line gets the remainder (largest-remainder method)\n   689→    SET lastLine.line_discount_amount = remainingDiscount\n   690→\n   691→    RECALCULATE line_total_amount for each affected line\n   692→```\n   693→\n   694→**Storage of allocations:**\n   695→- During checkout: update `cart_lines.line_discount_amount`.\n   696→- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n   697→\n   698→### 7.7 Usage Tracking\n   699→\n   700→When an order is successfully created with a discount code:\n   701→- Increment `discounts.usage_count` by 1.\n   702→- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n   703→\n   704→---\n   705→\n   706→## 8. Tax Calculation\n   707→\n   708→### 8.1 Tax Provider Architecture\n   709→\n   710→**Interface:** `App\\Contracts\\TaxProvider`\n   711→\n   712→- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n   713→- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n   714→- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n   715→\n   716→**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n   717→\n   718→**Implementations:**\n   719→- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n   720→- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n   721→\n   722→### 8.2 Manual Tax Mode\n   723→\n   724→**Rate resolution:**\n   725→1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n   726→2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n   727→3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n   728→\n   729→**Calculation per line:**\n   730→\n   731→```\n   732→line_tax = ROUND(line_total_after_discount * rate / 10000)\n   733→```\n   734→\n   735→Round each line's tax independently, then sum for the total tax amount.\n   736→\n   737→### 8.3 Prices Include Tax\n   738→\n   739→Controlled by `tax_settings.prices_include_tax` (boolean).\n   740→\n   741→#### Tax-inclusive pricing (prices_include_tax = true)\n   742→\n   743→- Displayed prices on the storefront are gross (include tax).\n   744→- Subtotal shown to customer is gross.\n   745→- Tax is \"extracted\" from the gross amount:\n   746→\n   747→```\n   748→net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\n   749→tax = gross_amount - net_amount\n   750→```\n   751→\n   752→> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n   753→\n   754→**Example:** Price = 1190 cents, rate = 1900 (19%).\n   755→- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n   756→- tax = 1190 - 1000 = 190\n   757→\n   758→#### Tax-exclusive pricing (prices_include_tax = false)\n   759→\n   760→- Displayed prices are net (excluding tax).\n   761→- Tax added on top:\n   762→\n   763→```\n   764→tax = ROUND(net_amount * rate / 10000)\n   765→total = net_amount + tax\n   766→```\n   767→\n   768→**Example:** Price = 1000 cents, rate = 1900.\n   769→- tax = ROUND(1000 * 1900 / 10000) = 190\n   770→- total = 1190\n   771→\n   772→### 8.4 Stripe Tax Provider (Stub)\n   773→\n   774→**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n   775→\n   776→- Sends line items and shipping address to Stripe Tax API.\n   777→- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n   778→- Fallback behavior (configurable per store in `tax_settings.config_json`):\n   779→\n   780→| Fallback Setting | Behavior |\n   781→|---|---|\n   782→| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n   783→| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n   784→\n   785→### 8.5 Discount-Before-Tax Semantics\n   786→\n   787→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   788→\n   789→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   790→\n   791→**Calculation order within the pricing pipeline:**\n   792→1. Calculate line subtotals.\n   793→2. Apply discount allocations to lines.\n   794→3. Calculate tax on discounted line amounts.\n   795→4. Sum everything for the total.\n   796→\n   797→---\n   798→\n   799→## 9. Shipping Calculation"}]},"uuid":"d9fc224b-9fda-4065-bf67-809fdcc897a6","timestamp":"2026-02-11T15:01:14.300Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation","numLines":200,"startLine":600,"totalLines":1583}},"sourceToolAssistantUUID":"cd9fd44e-bf4d-43b5-ba0a-292bf3687975"}
{"parentUuid":"d9fc224b-9fda-4065-bf67-809fdcc897a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017fqC65izJXZddMPUWBvFSc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016szdYr34KCpS76z1kTznRD","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":998,"limit":500}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":9069,"cache_read_input_tokens":100414,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":9069},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UwkTeRSUSh8YA4kSHq","type":"assistant","uuid":"c3ebf469-6c17-4201-a285-0b9a91515f2e","timestamp":"2026-02-11T15:01:18.659Z"}
{"parentUuid":"c3ebf469-6c17-4201-a285-0b9a91515f2e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_016szdYr34KCpS76z1kTznRD","toolUseID":"toolu_016szdYr34KCpS76z1kTznRD","timestamp":"2026-02-11T15:01:18.661Z","uuid":"e61ca17d-a283-4a93-8f7e-ac568fac67bb"}
{"parentUuid":"c3ebf469-6c17-4201-a285-0b9a91515f2e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016szdYr34KCpS76z1kTznRD","type":"tool_result","content":"   998→**Product 3: \"Organic Hoodie\"**\n   999→\n  1000→| Field | Value |\n  1001→|-------|-------|\n  1002→| handle | organic-hoodie |\n  1003→| status | active |\n  1004→| vendor | Acme Basics |\n  1005→| product_type | Hoodies |\n  1006→| tags | [\"new\", \"trending\"] |\n  1007→| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n  1008→| published_at | now |\n  1009→\n  1010→Collections: New Arrivals\n  1011→\n  1012→Options: Size (S, M, L, XL)\n  1013→\n  1014→Variants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n  1015→\n  1016→---\n  1017→\n  1018→**Product 4: \"Leather Belt\"**\n  1019→\n  1020→| Field | Value |\n  1021→|-------|-------|\n  1022→| handle | leather-belt |\n  1023→| status | active |\n  1024→| vendor | Acme Accessories |\n  1025→| product_type | Accessories |\n  1026→| tags | [\"popular\"] |\n  1027→| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n  1028→| published_at | now |\n  1029→\n  1030→Collections: (none)\n  1031→\n  1032→Options: Size (S/M, L/XL), Color (Brown, Black)\n  1033→\n  1034→Variants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n  1035→\n  1036→---\n  1037→\n  1038→**Product 5: \"Running Sneakers\"**\n  1039→\n  1040→| Field | Value |\n  1041→|-------|-------|\n  1042→| handle | running-sneakers |\n  1043→| status | active |\n  1044→| vendor | Acme Sport |\n  1045→| product_type | Shoes |\n  1046→| tags | [\"trending\"] |\n  1047→| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n  1048→| published_at | now |\n  1049→\n  1050→Collections: New Arrivals\n  1051→\n  1052→Options: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n  1053→\n  1054→Variants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n  1055→\n  1056→---\n  1057→\n  1058→**Product 6: \"Graphic Print Tee\"**\n  1059→\n  1060→| Field | Value |\n  1061→|-------|-------|\n  1062→| handle | graphic-print-tee |\n  1063→| status | active |\n  1064→| vendor | Acme Basics |\n  1065→| product_type | T-Shirts |\n  1066→| tags | [\"new\"] |\n  1067→| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n  1068→| published_at | now |\n  1069→\n  1070→Collections: T-Shirts\n  1071→\n  1072→Options: Size (S, M, L, XL)\n  1073→\n  1074→Variants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n  1075→\n  1076→---\n  1077→\n  1078→**Product 7: \"V-Neck Linen Tee\"**\n  1079→\n  1080→| Field | Value |\n  1081→|-------|-------|\n  1082→| handle | v-neck-linen-tee |\n  1083→| status | active |\n  1084→| vendor | Acme Basics |\n  1085→| product_type | T-Shirts |\n  1086→| tags | [\"popular\"] |\n  1087→| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n  1088→| published_at | now |\n  1089→\n  1090→Collections: T-Shirts\n  1091→\n  1092→Options: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n  1093→\n  1094→Variants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n  1095→\n  1096→---\n  1097→\n  1098→**Product 8: \"Striped Polo Shirt\"**\n  1099→\n  1100→| Field | Value |\n  1101→|-------|-------|\n  1102→| handle | striped-polo-shirt |\n  1103→| status | active |\n  1104→| vendor | Acme Basics |\n  1105→| product_type | T-Shirts |\n  1106→| tags | [\"sale\"] |\n  1107→| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n  1108→| published_at | now |\n  1109→\n  1110→Collections: T-Shirts, Sale\n  1111→\n  1112→Options: Size (S, M, L, XL)\n  1113→\n  1114→Variants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n  1115→\n  1116→---\n  1117→\n  1118→**Product 9: \"Cargo Pants\"**\n  1119→\n  1120→| Field | Value |\n  1121→|-------|-------|\n  1122→| handle | cargo-pants |\n  1123→| status | active |\n  1124→| vendor | Acme Workwear |\n  1125→| product_type | Pants |\n  1126→| tags | [\"popular\"] |\n  1127→| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n  1128→| published_at | now |\n  1129→\n  1130→Collections: Pants & Jeans\n  1131→\n  1132→Options: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n  1133→\n  1134→Variants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n  1135→\n  1136→---\n  1137→\n  1138→**Product 10: \"Chino Shorts\"**\n  1139→\n  1140→| Field | Value |\n  1141→|-------|-------|\n  1142→| handle | chino-shorts |\n  1143→| status | active |\n  1144→| vendor | Acme Basics |\n  1145→| product_type | Pants |\n  1146→| tags | [\"new\", \"trending\"] |\n  1147→| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n  1148→| published_at | now |\n  1149→\n  1150→Collections: Pants & Jeans, New Arrivals\n  1151→\n  1152→Options: Size (30, 32, 34, 36), Color (Navy, Sand)\n  1153→\n  1154→Variants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n  1155→\n  1156→---\n  1157→\n  1158→**Product 11: \"Wide Leg Trousers\"**\n  1159→\n  1160→| Field | Value |\n  1161→|-------|-------|\n  1162→| handle | wide-leg-trousers |\n  1163→| status | active |\n  1164→| vendor | Acme Denim |\n  1165→| product_type | Pants |\n  1166→| tags | [\"sale\"] |\n  1167→| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n  1168→| published_at | now |\n  1169→\n  1170→Collections: Pants & Jeans, Sale\n  1171→\n  1172→Options: Size (S, M, L)\n  1173→\n  1174→Variants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n  1175→\n  1176→---\n  1177→\n  1178→**Product 12: \"Wool Scarf\"**\n  1179→\n  1180→| Field | Value |\n  1181→|-------|-------|\n  1182→| handle | wool-scarf |\n  1183→| status | active |\n  1184→| vendor | Acme Accessories |\n  1185→| product_type | Accessories |\n  1186→| tags | [\"popular\"] |\n  1187→| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n  1188→| published_at | now |\n  1189→\n  1190→Collections: (none)\n  1191→\n  1192→Options: Color (Grey, Burgundy, Navy)\n  1193→\n  1194→Variants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n  1195→\n  1196→---\n  1197→\n  1198→**Product 13: \"Canvas Tote Bag\"**\n  1199→\n  1200→| Field | Value |\n  1201→|-------|-------|\n  1202→| handle | canvas-tote-bag |\n  1203→| status | active |\n  1204→| vendor | Acme Accessories |\n  1205→| product_type | Accessories |\n  1206→| tags | [\"trending\"] |\n  1207→| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n  1208→| published_at | now |\n  1209→\n  1210→Collections: (none)\n  1211→\n  1212→Options: Color (Natural, Black)\n  1213→\n  1214→Variants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n  1215→\n  1216→---\n  1217→\n  1218→**Product 14: \"Bucket Hat\"**\n  1219→\n  1220→| Field | Value |\n  1221→|-------|-------|\n  1222→| handle | bucket-hat |\n  1223→| status | active |\n  1224→| vendor | Acme Accessories |\n  1225→| product_type | Accessories |\n  1226→| tags | [\"new\", \"trending\"] |\n  1227→| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n  1228→| published_at | now |\n  1229→\n  1230→Collections: New Arrivals\n  1231→\n  1232→Options: Size (S/M, L/XL), Color (Beige, Black, Olive)\n  1233→\n  1234→Variants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n  1235→\n  1236→---\n  1237→\n  1238→**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n  1239→\n  1240→| Field | Value |\n  1241→|-------|-------|\n  1242→| handle | unreleased-winter-jacket |\n  1243→| status | **draft** |\n  1244→| vendor | Acme Outerwear |\n  1245→| product_type | Jackets |\n  1246→| tags | [\"limited\"] |\n  1247→| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n  1248→| published_at | **null** |\n  1249→\n  1250→Collections: (none - draft not assigned)\n  1251→\n  1252→Options: Size (S, M, L, XL)\n  1253→\n  1254→Variants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n  1255→\n  1256→**Test purpose:** Verifies draft products do not appear on the storefront.\n  1257→\n  1258→---\n  1259→\n  1260→**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n  1261→\n  1262→| Field | Value |\n  1263→|-------|-------|\n  1264→| handle | discontinued-raincoat |\n  1265→| status | **archived** |\n  1266→| vendor | Acme Outerwear |\n  1267→| product_type | Jackets |\n  1268→| tags | [] |\n  1269→| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n  1270→| published_at | 6 months ago |\n  1271→\n  1272→Collections: (none)\n  1273→\n  1274→Options: Size (M, L)\n  1275→\n  1276→Variants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n  1277→\n  1278→**Test purpose:** Verifies archived products do not appear on the storefront.\n  1279→\n  1280→---\n  1281→\n  1282→**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n  1283→\n  1284→| Field | Value |\n  1285→|-------|-------|\n  1286→| handle | limited-edition-sneakers |\n  1287→| status | active |\n  1288→| vendor | Acme Sport |\n  1289→| product_type | Shoes |\n  1290→| tags | [\"limited\"] |\n  1291→| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n  1292→| published_at | now |\n  1293→\n  1294→Collections: (none)\n  1295→\n  1296→Options: Size (EU 40, EU 42, EU 44)\n  1297→\n  1298→Variants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n  1299→\n  1300→**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n  1301→\n  1302→---\n  1303→\n  1304→**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n  1305→\n  1306→| Field | Value |\n  1307→|-------|-------|\n  1308→| handle | backorder-denim-jacket |\n  1309→| status | active |\n  1310→| vendor | Acme Denim |\n  1311→| product_type | Jackets |\n  1312→| tags | [\"popular\"] |\n  1313→| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n  1314→| published_at | now |\n  1315→\n  1316→Collections: (none)\n  1317→\n  1318→Options: Size (S, M, L, XL)\n  1319→\n  1320→Variants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n  1321→\n  1322→**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n  1323→\n  1324→---\n  1325→\n  1326→**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n  1327→\n  1328→| Field | Value |\n  1329→|-------|-------|\n  1330→| handle | gift-card |\n  1331→| status | active |\n  1332→| vendor | Acme Fashion |\n  1333→| product_type | Gift Cards |\n  1334→| tags | [\"popular\"] |\n  1335→| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n  1336→| published_at | now |\n  1337→\n  1338→Collections: (none)\n  1339→\n  1340→Options: Amount (25 EUR, 50 EUR, 100 EUR)\n  1341→\n  1342→Variants (3 total - each a different denomination):\n  1343→\n  1344→| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n  1345→|-----|---------------|--------|-------------------|-----------|\n  1346→| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n  1347→| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n  1348→| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n  1349→\n  1350→**Test purpose:** Tests digital product flow (no shipping step in checkout).\n  1351→\n  1352→---\n  1353→\n  1354→**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n  1355→\n  1356→| Field | Value |\n  1357→|-------|-------|\n  1358→| handle | cashmere-overcoat |\n  1359→| status | active |\n  1360→| vendor | Acme Premium |\n  1361→| product_type | Jackets |\n  1362→| tags | [\"limited\", \"new\"] |\n  1363→| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n  1364→| published_at | now |\n  1365→\n  1366→Collections: New Arrivals\n  1367→\n  1368→Options: Size (S, M, L), Color (Camel, Charcoal)\n  1369→\n  1370→Variants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n  1371→\n  1372→**Test purpose:** Tests high-value price display (499.99 EUR).\n  1373→\n  1374→---\n  1375→\n  1376→#### Product Summary Table (Acme Fashion)\n  1377→\n  1378→| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n  1379→|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n  1380→| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n  1381→| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n  1382→| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n  1383→| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n  1384→| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n  1385→| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n  1386→| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n  1387→| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n  1388→| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n  1389→| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n  1390→| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n  1391→| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n  1392→| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n  1393→| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n  1394→| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n  1395→| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n  1396→| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n  1397→| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n  1398→| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n  1399→| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n  1400→\n  1401→**Total variants across Acme Fashion:** 107\n  1402→\n  1403→---\n  1404→\n  1405→#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n  1406→\n  1407→These products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n  1408→\n  1409→| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n  1410→|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n  1411→| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n  1412→| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n  1413→| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n  1414→| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n  1415→| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n  1416→\n  1417→All active, published now, tags vary. Inventory policy = \"deny\" for all.\n  1418→\n  1419→**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n  1420→\n  1421→---\n  1422→\n  1423→### 3.11 DiscountSeeder\n  1424→\n  1425→**Acme Fashion - 5 discount codes:**\n  1426→\n  1427→| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n  1428→|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n  1429→| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n  1430→| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n  1431→| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n  1432→| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n  1433→| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n  1434→\n  1435→**Behavioral expectations:**\n  1436→- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n  1437→- FLAT5: Applies flat 5.00 EUR discount. Never used.\n  1438→- FREESHIP: Removes shipping cost. Used once.\n  1439→- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n  1440→- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n  1441→\n  1442→---\n  1443→\n  1444→### 3.12 CustomerSeeder\n  1445→\n  1446→**Acme Fashion - 10 customers:**\n  1447→\n  1448→| # | Email | Name | Password (plaintext) | marketing_opt_in |\n  1449→|---|-------|------|----------------------|------------------|\n  1450→| 1 | customer@acme.test | John Doe | password | 1 |\n  1451→| 2 | jane@example.com | Jane Smith | password | 0 |\n  1452→| 3 | michael@example.com | Michael Brown | password | 1 |\n  1453→| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n  1454→| 5 | david@example.com | David Lee | password | 1 |\n  1455→| 6 | emma@example.com | Emma Garcia | password | 0 |\n  1456→| 7 | james@example.com | James Taylor | password | 0 |\n  1457→| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n  1458→| 9 | robert@example.com | Robert Martinez | password | 0 |\n  1459→| 10 | anna@example.com | Anna Thomas | password | 1 |\n  1460→\n  1461→**Customer 1 (customer@acme.test) addresses:**\n  1462→\n  1463→| Label | is_default | Address Details |\n  1464→|-------|-----------|-----------------|\n  1465→| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n  1466→| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n  1467→\n  1468→**Customer 2 (jane@example.com) addresses:**\n  1469→\n  1470→| Label | is_default | Address Details |\n  1471→|-------|-----------|-----------------|\n  1472→| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n  1473→\n  1474→**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n  1475→\n  1476→**Acme Electronics - 2 customers (tenant isolation testing):**\n  1477→\n  1478→| # | Email | Name | Password |\n  1479→|---|-------|------|----------|\n  1480→| 1 | techfan@example.com | Tech Fan | password |\n  1481→| 2 | gadgetlover@example.com | Gadget Lover | password |\n  1482→\n  1483→Each has one default address with Faker-generated data.\n  1484→\n  1485→---\n  1486→\n  1487→### 3.13 OrderSeeder\n  1488→\n  1489→**Acme Fashion - 15 orders:**\n  1490→\n  1491→All orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n  1492→\n  1493→---\n  1494→\n  1495→**Order #1001 - Awaiting fulfillment**\n  1496→\n  1497→| Field | Value |"}]},"uuid":"63c00ca3-3324-4130-ac83-ed9c9c2f0078","timestamp":"2026-02-11T15:01:18.660Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |","numLines":500,"startLine":998,"totalLines":2215}},"sourceToolAssistantUUID":"c3ebf469-6c17-4201-a285-0b9a91515f2e"}
{"parentUuid":"63c00ca3-3324-4130-ac83-ed9c9c2f0078","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017fqC65izJXZddMPUWBvFSc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ASeQFEmvWQpMZ7NKpmNvFm","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":798,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":9069,"cache_read_input_tokens":100414,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":9069},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UwkTeRSUSh8YA4kSHq","type":"assistant","uuid":"fccbbb26-dfdb-4ca2-979d-20bcdd4949f6","timestamp":"2026-02-11T15:01:19.505Z"}
{"parentUuid":"fccbbb26-dfdb-4ca2-979d-20bcdd4949f6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01ASeQFEmvWQpMZ7NKpmNvFm","toolUseID":"toolu_01ASeQFEmvWQpMZ7NKpmNvFm","timestamp":"2026-02-11T15:01:19.509Z","uuid":"2af1b46b-2d8a-4d9c-8101-db02986d2723"}
{"parentUuid":"fccbbb26-dfdb-4ca2-979d-20bcdd4949f6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ASeQFEmvWQpMZ7NKpmNvFm","type":"tool_result","content":"   798→\n   799→## 9. Shipping Calculation\n   800→\n   801→### 9.1 Zone Matching\n   802→\n   803→**Service:** `App\\Services\\ShippingCalculator`\n   804→\n   805→**Zone matching algorithm:**\n   806→\n   807→```\n   808→PROCEDURE getMatchingZone(address, store):\n   809→    zones = load all shipping zones for store\n   810→\n   811→    bestMatch = null\n   812→    bestSpecificity = -1\n   813→\n   814→    FOR EACH zone IN zones:\n   815→        countryMatch = address.country_code IN zone.countries_json\n   816→        regionMatch = address.province_code IN zone.regions_json\n   817→\n   818→        IF countryMatch AND regionMatch:\n   819→            specificity = 2  // Most specific\n   820→        ELSE IF countryMatch:\n   821→            specificity = 1  // Country-only match\n   822→        ELSE:\n   823→            CONTINUE  // No match\n   824→\n   825→        IF specificity > bestSpecificity:\n   826→            bestMatch = zone\n   827→            bestSpecificity = specificity\n   828→        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n   829→            bestMatch = zone  // Tie-break: lowest ID wins\n   830→\n   831→    RETURN bestMatch  // null if no zone matches\n   832→```\n   833→\n   834→**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n   835→\n   836→### 9.2 Rate Types\n   837→\n   838→For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n   839→\n   840→#### Flat rate\n   841→\n   842→Config structure: `{\"amount\": 500}`\n   843→\n   844→- Cost = fixed amount regardless of cart contents.\n   845→\n   846→#### Weight-based rate\n   847→\n   848→Config structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n   849→\n   850→```\n   851→PROCEDURE calculateWeightRate(config, cart):\n   852→    totalWeight = 0\n   853→    FOR EACH line IN cart.lines:\n   854→        IF line.variant.requires_shipping = true:\n   855→            totalWeight += line.variant.weight_g * line.quantity\n   856→\n   857→    FOR EACH range IN config.ranges:\n   858→        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n   859→            RETURN range.amount\n   860→\n   861→    RETURN null  // Rate not available for this weight\n   862→```\n   863→\n   864→#### Price-based rate\n   865→\n   866→Config structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n   867→\n   868→```\n   869→PROCEDURE calculatePriceRate(config, cartSubtotal):\n   870→    FOR EACH range IN config.ranges:\n   871→        IF range.min_amount <= cartSubtotal:\n   872→            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n   873→                RETURN range.amount\n   874→\n   875→    RETURN null  // Rate not available for this subtotal\n   876→```\n   877→\n   878→A range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n   879→\n   880→#### Carrier-calculated rate (stub)\n   881→\n   882→Config structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n   883→\n   884→- Placeholder for future carrier API integration.\n   885→- Returns a hardcoded rate or error until implemented.\n   886→\n   887→### 9.3 Requires Shipping Check\n   888→\n   889→- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n   890→- shipping_amount = 0 in the pricing result.\n   891→- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n   892→\n   893→**Mixed carts** (some physical, some digital):\n   894→- Shipping is calculated only for physical items' weight.\n   895→- Digital items are excluded from weight calculations.\n   896→\n   897→### 9.4 Free Shipping Discount Override\n   898→\n   899→If a validated discount has `value_type = 'free_shipping'`:\n   900→- Override the calculated shipping amount to 0.\n   901→- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n   902→\n   903→---\n   904→\n   905→## 10. Payment Processing\n   906→\n   907→### 10.1 Payment Provider Architecture\n   908→\n   909→**Interface:** `App\\Contracts\\PaymentProvider`\n   910→\n   911→Must implement:\n   912→- `createSession(checkout)` - returns a PaymentSession value object (redirect URL, client secret, etc.)\n   913→- `handleWebhook(request)` - processes incoming webhook, returns a WebhookResult\n   914→- `refund(payment, amount)` - initiates a refund, returns a RefundResult\n   915→\n   916→**Service:** `App\\Services\\PaymentService` - orchestrates provider selection based on store settings and delegates.\n   917→\n   918→**Provider selection:** Configured per store in `store_settings.settings_json` key `payment_providers` (array of provider strings).\n   919→\n   920→### 10.2 Stripe Integration\n   921→\n   922→**Implementation:** `App\\Services\\Payment\\StripePaymentProvider`\n   923→\n   924→```mermaid\n   925→sequenceDiagram\n   926→    participant Customer\n   927→    participant Storefront\n   928→    participant Backend\n   929→    participant Stripe\n   930→\n   931→    Customer->>Storefront: Click \"Pay with Stripe\"\n   932→    Storefront->>Backend: POST /api/storefront/v1/payments/stripe/checkout-session\n   933→    Backend->>Stripe: Create Checkout Session (line items, success/cancel URLs, metadata with checkout ID)\n   934→    Stripe-->>Backend: Session URL\n   935→    Backend-->>Storefront: Redirect URL\n   936→    Storefront->>Customer: Redirect to Stripe hosted page\n   937→    Customer->>Stripe: Complete payment\n   938→    Stripe->>Customer: Redirect to success_url\n   939→    Stripe->>Backend: Webhook: checkout.session.completed\n   940→    Backend->>Backend: Verify signature, check idempotency\n   941→    Backend->>Backend: Queue ProcessStripeWebhook job\n   942→    Backend->>Backend: Complete checkout, create order (idempotent)\n   943→```\n   944→\n   945→**Webhook endpoint:** `POST /api/webhooks/stripe`\n   946→\n   947→**Webhook handling requirements:**\n   948→1. Verify `Stripe-Signature` header using the webhook secret (stored in config, sourced from environment variable `STRIPE_WEBHOOK_SECRET`).\n   949→2. If signature is invalid: return HTTP 400 immediately. Do not process.\n   950→3. Check idempotency: look up the Stripe event ID in a processed events cache or table. If already processed, return HTTP 200 immediately.\n   951→4. Dispatch a queued job for processing to ensure resilience.\n   952→\n   953→**Handled Stripe events:**\n   954→\n   955→| Event | Action |\n   956→|---|---|\n   957→| `checkout.session.completed` | Complete the checkout, create order |\n   958→| `payment_intent.succeeded` | Update payment status to captured (if not using Checkout Sessions) |\n   959→| `payment_intent.payment_failed` | Mark payment as failed, release inventory, notify customer |\n   960→| `charge.refunded` | Update order financial status, create refund record |\n   961→\n   962→### 10.3 PayPal Integration (Stub)\n   963→\n   964→**Implementation:** `App\\Services\\Payment\\PayPalPaymentProvider`\n   965→\n   966→**Flow:**\n   967→1. Create order via PayPal Orders API (v2).\n   968→2. Return the PayPal approval URL for customer redirect.\n   969→3. On return from PayPal: capture the payment.\n   970→4. Webhook for async confirmation.\n   971→5. Signature verification via PayPal's verification endpoint.\n   972→\n   973→### 10.4 Payment State Machine\n   974→\n   975→**Enum:** `App\\Enums\\PaymentStatus` - values: `requires_action`, `authorized`, `captured`, `failed`, `refunded`\n   976→\n   977→```mermaid\n   978→stateDiagram-v2\n   979→    [*] --> requires_action : Payment initiated\n   980→    requires_action --> authorized : Provider authorizes\n   981→    requires_action --> failed : Provider rejects\n   982→    authorized --> captured : Payment captured\n   983→    captured --> refunded : Full refund processed\n   984→\n   985→    note right of requires_action : Awaiting customer action or provider confirmation\n   986→    note right of authorized : For Stripe Checkout Sessions, authorized + captured happen together\n   987→```\n   988→\n   989→### 10.5 Queued Webhook Processing\n   990→\n   991→**Jobs:**\n   992→- `App\\Jobs\\ProcessStripeWebhook` - handles incoming Stripe webhook events\n   993→- `App\\Jobs\\ProcessPayPalWebhook` - handles incoming PayPal webhook events\n   994→\n   995→Using queued jobs ensures resilience: if processing fails, the job retries automatically per Laravel's retry configuration.\n   996→\n   997→---"}]},"uuid":"281dceb0-5f50-4e23-a0bb-cbfadf618df5","timestamp":"2026-02-11T15:01:19.509Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing\n\n### 10.1 Payment Provider Architecture\n\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `createSession(checkout)` - returns a PaymentSession value object (redirect URL, client secret, etc.)\n- `handleWebhook(request)` - processes incoming webhook, returns a WebhookResult\n- `refund(payment, amount)` - initiates a refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates provider selection based on store settings and delegates.\n\n**Provider selection:** Configured per store in `store_settings.settings_json` key `payment_providers` (array of provider strings).\n\n### 10.2 Stripe Integration\n\n**Implementation:** `App\\Services\\Payment\\StripePaymentProvider`\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant Stripe\n\n    Customer->>Storefront: Click \"Pay with Stripe\"\n    Storefront->>Backend: POST /api/storefront/v1/payments/stripe/checkout-session\n    Backend->>Stripe: Create Checkout Session (line items, success/cancel URLs, metadata with checkout ID)\n    Stripe-->>Backend: Session URL\n    Backend-->>Storefront: Redirect URL\n    Storefront->>Customer: Redirect to Stripe hosted page\n    Customer->>Stripe: Complete payment\n    Stripe->>Customer: Redirect to success_url\n    Stripe->>Backend: Webhook: checkout.session.completed\n    Backend->>Backend: Verify signature, check idempotency\n    Backend->>Backend: Queue ProcessStripeWebhook job\n    Backend->>Backend: Complete checkout, create order (idempotent)\n```\n\n**Webhook endpoint:** `POST /api/webhooks/stripe`\n\n**Webhook handling requirements:**\n1. Verify `Stripe-Signature` header using the webhook secret (stored in config, sourced from environment variable `STRIPE_WEBHOOK_SECRET`).\n2. If signature is invalid: return HTTP 400 immediately. Do not process.\n3. Check idempotency: look up the Stripe event ID in a processed events cache or table. If already processed, return HTTP 200 immediately.\n4. Dispatch a queued job for processing to ensure resilience.\n\n**Handled Stripe events:**\n\n| Event | Action |\n|---|---|\n| `checkout.session.completed` | Complete the checkout, create order |\n| `payment_intent.succeeded` | Update payment status to captured (if not using Checkout Sessions) |\n| `payment_intent.payment_failed` | Mark payment as failed, release inventory, notify customer |\n| `charge.refunded` | Update order financial status, create refund record |\n\n### 10.3 PayPal Integration (Stub)\n\n**Implementation:** `App\\Services\\Payment\\PayPalPaymentProvider`\n\n**Flow:**\n1. Create order via PayPal Orders API (v2).\n2. Return the PayPal approval URL for customer redirect.\n3. On return from PayPal: capture the payment.\n4. Webhook for async confirmation.\n5. Signature verification via PayPal's verification endpoint.\n\n### 10.4 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `requires_action`, `authorized`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> requires_action : Payment initiated\n    requires_action --> authorized : Provider authorizes\n    requires_action --> failed : Provider rejects\n    authorized --> captured : Payment captured\n    captured --> refunded : Full refund processed\n\n    note right of requires_action : Awaiting customer action or provider confirmation\n    note right of authorized : For Stripe Checkout Sessions, authorized + captured happen together\n```\n\n### 10.5 Queued Webhook Processing\n\n**Jobs:**\n- `App\\Jobs\\ProcessStripeWebhook` - handles incoming Stripe webhook events\n- `App\\Jobs\\ProcessPayPalWebhook` - handles incoming PayPal webhook events\n\nUsing queued jobs ensures resilience: if processing fails, the job retries automatically per Laravel's retry configuration.\n\n---","numLines":200,"startLine":798,"totalLines":1583}},"sourceToolAssistantUUID":"fccbbb26-dfdb-4ca2-979d-20bcdd4949f6"}
{"parentUuid":"281dceb0-5f50-4e23-a0bb-cbfadf618df5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SuRhNwR3BVvGM5svqy1vvX","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RTek3rMxEr11sgoBtEtGCX","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":1496,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":10859,"cache_read_input_tokens":109483,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":10859},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Ux8tXPTZZwAm34CYWW","type":"assistant","uuid":"c71f5c3f-7075-493e-b2d9-a2725d5a2626","timestamp":"2026-02-11T15:01:24.344Z"}
{"parentUuid":"c71f5c3f-7075-493e-b2d9-a2725d5a2626","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01RTek3rMxEr11sgoBtEtGCX","toolUseID":"toolu_01RTek3rMxEr11sgoBtEtGCX","timestamp":"2026-02-11T15:01:24.349Z","uuid":"c9a25c04-167b-448f-a07e-c40ea67de5f2"}
{"parentUuid":"c71f5c3f-7075-493e-b2d9-a2725d5a2626","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RTek3rMxEr11sgoBtEtGCX","type":"tool_result","content":"  1496→\n  1497→| Field | Value |\n  1498→|-------|-------|\n  1499→| customer | customer@acme.test (John Doe) |\n  1500→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1501→| placed_at | 2 days ago |\n  1502→\n  1503→Lines:\n  1504→\n  1505→| Product | Variant (option values) | Qty | Unit Price | Line Total |\n  1506→|---------|------------------------|-----|-----------|-----------|\n  1507→| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n  1508→\n  1509→Totals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n  1510→\n  1511→Payment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n  1512→\n  1513→Fulfillments: None.\n  1514→\n  1515→---\n  1516→\n  1517→**Order #1002 - Fully delivered**\n  1518→\n  1519→| Field | Value |\n  1520→|-------|-------|\n  1521→| customer | customer@acme.test (John Doe) |\n  1522→| status / financial / fulfillment | fulfilled / paid / fulfilled |\n  1523→| placed_at | 10 days ago |\n  1524→\n  1525→Lines:\n  1526→\n  1527→| Product | Variant | Qty | Unit Price | Line Total |\n  1528→|---------|---------|-----|-----------|-----------|\n  1529→| Organic Hoodie | M | 1 | 5999 | 5999 |\n  1530→| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n  1531→\n  1532→Totals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n  1533→\n  1534→Payment: stripe, \"pi_test_order1002\", captured, 8997\n  1535→\n  1536→Fulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n  1537→\n  1538→---\n  1539→\n  1540→**Order #1003 - Partially fulfilled**\n  1541→\n  1542→| Field | Value |\n  1543→|-------|-------|\n  1544→| customer | jane@example.com (Jane Smith) |\n  1545→| status / financial / fulfillment | paid / paid / partial |\n  1546→| placed_at | 5 days ago |\n  1547→\n  1548→Lines:\n  1549→\n  1550→| Product | Variant | Qty | Unit Price | Line Total |\n  1551→|---------|---------|-----|-----------|-----------|\n  1552→| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n  1553→| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n  1554→\n  1555→Totals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n  1556→\n  1557→Payment: stripe, \"pi_test_order1003\", captured, 11997\n  1558→\n  1559→Fulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n  1560→\n  1561→---\n  1562→\n  1563→**Order #1004 - Cancelled with full refund**\n  1564→\n  1565→| Field | Value |\n  1566→|-------|-------|\n  1567→| customer | customer@acme.test (John Doe) |\n  1568→| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n  1569→| placed_at | 15 days ago |\n  1570→\n  1571→Lines:\n  1572→\n  1573→| Product | Variant | Qty | Unit Price | Line Total |\n  1574→|---------|---------|-----|-----------|-----------|\n  1575→| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n  1576→\n  1577→Totals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n  1578→\n  1579→Payment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n  1580→\n  1581→Refund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n  1582→\n  1583→---\n  1584→\n  1585→**Order #1005 - Payment pending**\n  1586→\n  1587→| Field | Value |\n  1588→|-------|-------|\n  1589→| customer | jane@example.com (Jane Smith) |\n  1590→| status / financial / fulfillment | pending / pending / unfulfilled |\n  1591→| placed_at | 2 hours ago |\n  1592→\n  1593→Lines:\n  1594→\n  1595→| Product | Variant | Qty | Unit Price | Line Total |\n  1596→|---------|---------|-----|-----------|-----------|\n  1597→| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n  1598→\n  1599→Totals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n  1600→\n  1601→Payment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n  1602→\n  1603→---\n  1604→\n  1605→**Order #1006 - Standard paid order**\n  1606→\n  1607→| Field | Value |\n  1608→|-------|-------|\n  1609→| customer | michael@example.com (Michael Brown) |\n  1610→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1611→| placed_at | 1 day ago |\n  1612→\n  1613→Lines:\n  1614→\n  1615→| Product | Variant | Qty | Unit Price | Line Total |\n  1616→|---------|---------|-----|-----------|-----------|\n  1617→| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n  1618→\n  1619→Totals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n  1620→\n  1621→Payment: stripe, \"pi_test_order1006\", captured, 12498\n  1622→\n  1623→---\n  1624→\n  1625→**Order #1007 - Multi-item delivered**\n  1626→\n  1627→| Field | Value |\n  1628→|-------|-------|\n  1629→| customer | sarah@example.com (Sarah Wilson) |\n  1630→| status / financial / fulfillment | fulfilled / paid / fulfilled |\n  1631→| placed_at | 20 days ago |\n  1632→\n  1633→Lines:\n  1634→\n  1635→| Product | Variant | Qty | Unit Price | Line Total |\n  1636→|---------|---------|-----|-----------|-----------|\n  1637→| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n  1638→| Wool Scarf | Grey | 1 | 2999 | 2999 |\n  1639→\n  1640→Totals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n  1641→\n  1642→Payment: stripe, captured, 10496\n  1643→\n  1644→Fulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n  1645→\n  1646→---\n  1647→\n  1648→**Order #1008 - Partial refund**\n  1649→\n  1650→| Field | Value |\n  1651→|-------|-------|\n  1652→| customer | david@example.com (David Lee) |\n  1653→| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n  1654→| placed_at | 12 days ago |\n  1655→\n  1656→Lines:\n  1657→\n  1658→| Product | Variant | Qty | Unit Price | Line Total |\n  1659→|---------|---------|-----|-----------|-----------|\n  1660→| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n  1661→| Graphic Print Tee | L | 1 | 2999 | 2999 |\n  1662→\n  1663→Totals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n  1664→\n  1665→Payment: stripe, captured, 8997\n  1666→\n  1667→Fulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n  1668→\n  1669→Refund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n  1670→\n  1671→---\n  1672→\n  1673→**Order #1009 - Accessories order**\n  1674→\n  1675→| Field | Value |\n  1676→|-------|-------|\n  1677→| customer | emma@example.com (Emma Garcia) |\n  1678→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1679→| placed_at | 3 days ago |\n  1680→\n  1681→Lines:\n  1682→\n  1683→| Product | Variant | Qty | Unit Price | Line Total |\n  1684→|---------|---------|-----|-----------|-----------|\n  1685→| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n  1686→| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n  1687→\n  1688→Totals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n  1689→\n  1690→Payment: stripe, captured, 4997\n  1691→\n  1692→---\n  1693→\n  1694→**Order #1010 - High-value order**\n  1695→\n  1696→| Field | Value |\n  1697→|-------|-------|\n  1698→| customer | customer@acme.test (John Doe) |\n  1699→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1700→| placed_at | 1 day ago |\n  1701→\n  1702→Lines:\n  1703→\n  1704→| Product | Variant | Qty | Unit Price | Line Total |\n  1705→|---------|---------|-----|-----------|-----------|\n  1706→| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n  1707→\n  1708→Totals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n  1709→\n  1710→Payment: stripe, captured, 50498\n  1711→\n  1712→---\n  1713→\n  1714→**Order #1011 - Single item delivered**\n  1715→\n  1716→| Field | Value |\n  1717→|-------|-------|\n  1718→| customer | james@example.com (James Taylor) |\n  1719→| status / financial / fulfillment | paid / paid / fulfilled |\n  1720→| placed_at | 25 days ago |\n  1721→\n  1722→Lines:\n  1723→\n  1724→| Product | Variant | Qty | Unit Price | Line Total |\n  1725→|---------|---------|-----|-----------|-----------|\n  1726→| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n  1727→\n  1728→Totals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n  1729→\n  1730→Payment: stripe, captured, 3298\n  1731→\n  1732→Fulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n  1733→\n  1734→---\n  1735→\n  1736→**Order #1012 - Multi-quantity order**\n  1737→\n  1738→| Field | Value |\n  1739→|-------|-------|\n  1740→| customer | lisa@example.com (Lisa Anderson) |\n  1741→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1742→| placed_at | 4 days ago |\n  1743→\n  1744→Lines:\n  1745→\n  1746→| Product | Variant | Qty | Unit Price | Line Total |\n  1747→|---------|---------|-----|-----------|-----------|\n  1748→| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n  1749→\n  1750→Totals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n  1751→\n  1752→Payment: stripe, captured, 8497\n  1753→\n  1754→---\n  1755→\n  1756→**Order #1013 - Multi-item order**\n  1757→\n  1758→| Field | Value |\n  1759→|-------|-------|\n  1760→| customer | robert@example.com (Robert Martinez) |\n  1761→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1762→| placed_at | 1 day ago |\n  1763→\n  1764→Lines:\n  1765→\n  1766→| Product | Variant | Qty | Unit Price | Line Total |\n  1767→|---------|---------|-----|-----------|-----------|\n  1768→| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n  1769→| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n  1770→\n  1771→Totals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n  1772→\n  1773→Payment: stripe, captured, 8497\n  1774→\n  1775→---\n  1776→\n  1777→**Order #1014 - Digital product order**\n  1778→\n  1779→| Field | Value |\n  1780→|-------|-------|\n  1781→| customer | anna@example.com (Anna Thomas) |\n  1782→| status / financial / fulfillment | paid / paid / fulfilled |\n  1783→| placed_at | 14 days ago |\n  1784→\n  1785→Lines:\n  1786→\n  1787→| Product | Variant | Qty | Unit Price | Line Total |\n  1788→|---------|---------|-----|-----------|-----------|\n  1789→| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n  1790→\n  1791→Totals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n  1792→\n  1793→Payment: stripe, captured, 5000\n  1794→\n  1795→Fulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at"}]},"uuid":"fb55a541-6868-4f00-a4ed-c178950000f7","timestamp":"2026-02-11T15:01:24.349Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at","numLines":300,"startLine":1496,"totalLines":2215}},"sourceToolAssistantUUID":"c71f5c3f-7075-493e-b2d9-a2725d5a2626"}
{"parentUuid":"fb55a541-6868-4f00-a4ed-c178950000f7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MHCAQHVvv3w1MPFp7NPWmB","type":"message","role":"assistant","content":[{"type":"text","text":"Now I have the full picture. Here's the answer:\n\n---\n\n## Demo Shop Seed Data\n\n**Yes, there is a comprehensive demo seed scenario.** The primary demo store is **\"Acme Fashion\"** (a fashion/apparel store, currency EUR, timezone Europe/Berlin). A second store, **\"Acme Electronics\"**, exists purely for tenant isolation testing.\n\n### Products\n\n**Acme Fashion: 20 products, 107 variants total.** The catalog is a fashion shop:\n\n| # | Product | Price | Special Purpose |\n|---|---------|-------|-----------------|\n| 1 | Classic Cotton T-Shirt | 24.99 | Standard product, 12 variants (Size x Color) |\n| 2 | Premium Slim Fit Jeans | 79.99 (was 99.99) | **On sale** with compare-at price |\n| 3 | Organic Hoodie | 59.99 | Standard, 4 sizes |\n| 4 | Leather Belt | 34.99 | Size + Color options |\n| 5 | Running Sneakers | 119.99 | **Low stock** (5 per variant), 14 variants |\n| 6 | Graphic Print Tee | 29.99 | Standard |\n| 7 | V-Neck Linen Tee | 34.99 | 9 variants (Size x Color) |\n| 8 | Striped Polo Shirt | 27.99 (was 39.99) | **On sale** |\n| 9 | Cargo Pants | 54.99 | Size + Color, 12 variants |\n| 10 | Chino Shorts | 39.99 | Standard |\n| 11 | Wide Leg Trousers | 49.99 (was 69.99) | **On sale** |\n| 12 | Wool Scarf | 29.99 | Color-only option |\n| 13 | Canvas Tote Bag | 19.99 | 2 variants |\n| 14 | Bucket Hat | 24.99 | Size + Color |\n| 15 | Unreleased Winter Jacket | 149.99 | **Draft** - must not appear on storefront |\n| 16 | Discontinued Raincoat | 89.99 | **Archived** - must not appear on storefront |\n| 17 | Limited Edition Sneakers | 159.99 | **Sold out, policy=deny** - add-to-cart disabled |\n| 18 | Backorder Denim Jacket | 99.99 | **Out of stock, policy=continue** - purchasable on backorder |\n| 19 | Gift Card | 25/50/100 | **Digital** - no shipping required |\n| 20 | Cashmere Overcoat | 499.99 | **High-value** price display test |\n\n**Acme Electronics: 5 products** (laptop, headphones, USB cable, keyboard, monitor stand) - only for tenant isolation testing.\n\n### Are all capabilities covered?\n\nPretty well. The seed data deliberately covers:\n\n- **Product statuses:** active, draft, archived\n- **Inventory policies:** deny (sold out), continue (backorder), low stock, normal stock\n- **Pricing:** regular, compare-at/sale, multi-denomination (gift card), high-value\n- **Product types:** physical with options, digital (no shipping), single-variant (no options)\n- **Collections:** 4 collections (New Arrivals, T-Shirts, Pants & Jeans, Sale)\n- **Discounts:** 5 codes covering percent off (WELCOME10), fixed amount (FLAT5), free shipping (FREESHIP), expired (EXPIRED20), maxed out usage (MAXED)\n- **Orders:** 15 orders (#1001-#1015) covering paid/unfulfilled, fully delivered, partially fulfilled, cancelled with full refund, partial refund, pending payment, digital product order, high-value order, multi-item, multi-quantity\n- **Customers:** 10 customers with addresses (German addresses)\n- **Users/Roles:** owner, admin, staff, support\n- **Themes, pages, navigation, analytics, search settings** - all seeded\n\n**What is NOT covered by seed data:**\n- No `ProductMedia` records (no actual images seeded - storefront uses placeholders)\n- No apps or webhook subscriptions in the seed data\n- No automated collections (all are manual)\n- No weight-based or price-based shipping rates in seeded data (only flat rates)\n- No Stripe Tax provider config (only manual tax at 19%)\n\n---\n\n## Payment (PSP)\n\n**Yes, essentially dummy/test mode.**\n\n- **Stripe:** Real integration code but intended to run in **Stripe test mode** (`sk_test_fake` in `.env.testing`). Uses Stripe Checkout Sessions - the customer is redirected to Stripe's hosted payment page, then back. Webhook handling is fully specified (signature verification, idempotent order creation).\n- **PayPal:** **Stub only.** The interface (`PaymentProvider`) is implemented but the PayPal provider is described as a placeholder. The flow is defined (Orders API v2, redirect, capture) but not fully built.\n\nAll 15 seeded orders use **Stripe** as the provider with fake `pi_test_*` payment IDs.\n\nThere is **no payment method selection UI** described in detail - the spec implies Stripe is the primary (and effectively only working) gateway.\n\n---\n\n## Shipping Methods\n\nAll seeded shipping is **flat rate only.** The Acme Fashion store has 3 zones:\n\n| Zone | Countries | Rates |\n|------|-----------|-------|\n| Domestic | DE | Standard 4.99 EUR, Express 9.99 EUR |\n| EU | AT, FR, IT, ES, NL, BE, PL | EU Standard 8.99 EUR |\n| Rest of World | US, GB, CA, AU | International 14.99 EUR |\n\nAcme Electronics has one zone (Germany) with **free shipping**.\n\nThe **code supports 4 rate types** (flat, weight-based, price-based, carrier-calculated), but only flat rates are seeded. Carrier-calculated is a stub/placeholder for future carrier API integration.\n\nDigital products (Gift Card) skip the shipping step entirely."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4298,"cache_read_input_tokens":120342,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4298},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2UxVaED1FXMqoJJGtKJ","type":"assistant","uuid":"c0614c1d-e007-4a7d-a4e4-c92b1de6e59d","timestamp":"2026-02-11T15:02:00.395Z"}
{"parentUuid":"c0614c1d-e007-4a7d-a4e4-c92b1de6e59d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"Stop","hookName":"Stop","command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"},"parentToolUseID":"7d89e1df-c8eb-4f3e-8132-2ab9c9ed421e","toolUseID":"7d89e1df-c8eb-4f3e-8132-2ab9c9ed421e","timestamp":"2026-02-11T15:02:00.449Z","uuid":"a1705605-992e-4dc8-b024-77805045c4d9"}
{"parentUuid":"a1705605-992e-4dc8-b024-77805045c4d9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-02-11T15:02:00.461Z","uuid":"cf34df02-3137-4359-9552-bba6a0f762f7","toolUseID":"7d89e1df-c8eb-4f3e-8132-2ab9c9ed421e"}
{"parentUuid":"cf34df02-3137-4359-9552-bba6a0f762f7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"turn_duration","durationMs":58137,"timestamp":"2026-02-11T15:02:00.462Z","uuid":"b190fa6a-1752-49c6-a015-2bcb952f6958","isMeta":false}
{"type":"file-history-snapshot","messageId":"5d663fc0-4a17-452e-b15d-84f835bb4740","snapshot":{"messageId":"5d663fc0-4a17-452e-b15d-84f835bb4740","trackedFileBackups":{},"timestamp":"2026-02-11T15:04:00.940Z"},"isSnapshotUpdate":false}
{"parentUuid":"b190fa6a-1752-49c6-a015-2bcb952f6958","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"ok, I want an onsite checkout incl paymnt selection. Fake pre-auth and capture. No real PSP interation. It must fully work with a PSP mock.\n\nDo we have a order management system? How is the flow?\n\nWhat idscounts do we have in demo data?"},"uuid":"5d663fc0-4a17-452e-b15d-84f835bb4740","timestamp":"2026-02-11T15:04:00.939Z","thinkingMetadata":{"maxThinkingTokens":31999},"permissionMode":"acceptEdits"}
{"parentUuid":"5d663fc0-4a17-452e-b15d-84f835bb4740","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017sQYeUtW7hCUxgNyDeK36m","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":109,"cache_read_input_tokens":126152,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":109},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VA2sDQuPhA5oexQyZd","type":"assistant","uuid":"52205c8f-1cc5-470e-b2b2-b4db8f8cc6f4","timestamp":"2026-02-11T15:04:03.052Z"}
{"type":"queue-operation","operation":"enqueue","timestamp":"2026-02-11T15:04:09.015Z","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","content":"don't change specs yet"}
{"parentUuid":"52205c8f-1cc5-470e-b2b2-b4db8f8cc6f4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017sQYeUtW7hCUxgNyDeK36m","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking three things:\n\n1. They want an onsite checkout with payment selection, using a fake/mock PSP (no real Stripe/PayPal integration). They want fake pre-auth and capture.\n\n2. They want to know about the order management system - what's the flow?\n\n3. What discounts are in the demo data?\n\nLet me answer these questions based on what I've already read from the specs. I don't need to read more files - I have all the information.\n\n### 1. Onsite Checkout with Mock PSP\n\nThe current spec has Stripe Checkout Sessions (redirect to Stripe) and a PayPal stub. The user wants:\n","signature":"ErgNCkYICxgCKkDCDn/u9tBu+BzQcsK7hb7snxLB5mrteq9wmOxjKj24oLTzgFx/T0s36MFJg1sPi0usxl4Xtk75X36ypwoxv6UsEgxFafUdzv/q18j/O90aDLfLYkvHUI7feVhILSIwUgvivnIzhDS87lwnv8cbnYaOiJdtG4lcmWXdd9mF1KPRioPpZgmEBbu1v9UflU1EKp8MjQGAcDwK1otV/ZBkElBiYb3brNYUZd9Y+hHgOGOhMJeUNeyh8IJn7ySc99P/vhnOBor0ZEZK44IJGGOJ2G+R3H4FSkxsAAIZnkROUH1BvL9LWyp725fBCjB41oDx1j882Oxkt/upxXaHK6H3PEPz363/SDMPA3aEtmEiN+Y+C7txR79jT8kh8REKshDPReWo9YmarjVQDmDXIR4dH5Y2xJb0it/TbrOXkX7AFgcMTjDeo/K1i81fUQvOJKCaUjffHwmw9owtqYf/hrnHKL5Mgby5430EyohrR25GTiDgWUxenKB3X5l/J5F3BnWAj1t72NoJg8UggV66mSODpfz5IZ2mwC4m5rSizGYXK55nF8ahKLpFtKinZExACt37+vB5XEFYagn4DutoIq6XcJ0MURHYQdDOMgbzpU+DdvXw7V+6liqFmewoaoCcoPUgiTxjabkBPyHOtevD9Du00fjUgHpWcNQeAPt2ustg3uRv5xuMcxATakwsBmZt/9ew8767cm3RTK94ZFVq0MS7tIDxzFLbWoeR+oQY/FMuxtJ7a5ZLl8M7YRR1ooFpHueaacyoE3QBTvx3PK9/OG7ESlWzQJ+aiOTSOC0hg2TV/yuVBONHLB5HtPFafYqGkXZvanNxgjkxpkq3QxYisS9Ne6siLZrDvUFIAWJ3WbgSq1SHDRgN/ncfezRMMn3Inn0PxtgGriXoHZCB9qL15FIkwO6cK9DLamoYBKEaHjKTsvsIr4a5nK5VV6CkbBfspGqbsL2IQe+cvF/DxuDQ5eE+8MK+Bc3ATK4pWaeyP0HVdo/SFm4y8W38tmtlvLRoA6iHLNM82axByMeiEueJAW9kVWyzNTe1Qu01GmoZ1hMMpEGObi9SRK5uyMsJE5dqJP8sKf3eaGqM3uDGCVv239yDo+r7Vr/iw4hzdqMpaUV5/cF42u0R9EZTxrhTtvHodYIDr+HiieQcKSw08fGru9ovr8rKifejwzgNgG+k9hGwctsEpQ5aLZMAVhPwWFC7iN/n1VUaJjALWhdFzHNv+2Tzjobbb6V8nwIIzTxZDxlQMi1IqGcciLX1fzaunHFBwIhRR+8cBinyUKuQePzHjYerft8bSYLYquaHEEhYpFBi7SPJ9ylFtLzMQfNurgwByRF3epqtJwz/FNhFqXpnPuikMDUiNR0v3JlKzVZNkzWbMT3Q5AKI+iZa9iNC9b+8oUTG2/33x8Y1HuZKjon335cMiG5m/a/5t9NR86mfHXmzisIl/0K3eBfDdfurym34DjPAaXRHgQKZBHbuRVFO4FWFET9GMwkrWe5sAmRh7MFe0qLXrOOXTgd9Qd9Wy5SQOjwDDDlfaQwM/xkBrmAhRYTV4cAAJMKeFdi/WOmLU1caXkNyk1b2A4VzSfzB0+ZMiz1x2l3xcsh0tiQCITXKYpCvrCHjDFkcfGpc3B7A4QyJwllqL/lI3ZODOz3yve7Gx7rrax1Bgw5egQgrYBb15VqlB7hV9+1vfTzQqOTsQSDe7DTXyfdajnpWU6n8p73n+enTyPwxStI0oRXqA7mxiAxFMSNyYJjQyvcMbUhWDKPd9HbnM+CrOsUQsZ3y06N7BmMdohKijcetriGyvcK7C6dluZHWxk3LJaM6k1FBUX2s2pnj4Z43oC343dbv6wVN2hRqLNBxnHqCLzq1BksKYJ5d6IQIKVUMfuOZX+FnGNdm5vAFIYmoVqVY5BM9viNi7cFtmTv5W38qFaCQrmkGXSBWtV1VagDDN1UHbzmpjpFClQXzr+kf06srDvLrX9vijX8n+LYcfFvfx4nklN+qCP4uIam/SL4HqIKBVt28mWF5+a6IVtS/xAObuF4+pZJA+Owl5XSeLrdAvzXGQbt3BJR//HFBcv3eyL7A5u3t08lTpZFMyUaDLabE+IbzqJgC2IXs+OAqTKdOy08QqQaTFvFincrcomGcJA1tD1Ut8iNVYC8OSWDgWh3oJBjGSQDt4NdtoRv5WGB/53B2hZZ4vpHCy2u0PP7e9VB3R063YChpw2R1rKNrKyc6UzHuLwe9THsI1P8pw0kJkXjwgEj08oy3B3FD7ZbZZscg2vIXbDmmYykiThgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":109,"cache_read_input_tokens":126152,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":109},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VA2sDQuPhA5oexQyZd","type":"assistant","uuid":"e383c40e-94ac-4487-98d9-ff1b0255f77d","timestamp":"2026-02-11T15:04:38.533Z"}
{"parentUuid":"e383c40e-94ac-4487-98d9-ff1b0255f77d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017sQYeUtW7hCUxgNyDeK36m","type":"message","role":"assistant","content":[{"type":"text","text":"## 1. Discounts in Demo Data\n\nFive discount codes are seeded for Acme Fashion:\n\n| Code | Type | Effect | Rules / Notes |\n|------|------|--------|---------------|\n| **WELCOME10** | percent | 10% off | Min purchase 20.00 EUR. Already used 3x, no usage limit. |\n| **FLAT5** | fixed | 5.00 EUR off | No restrictions. Never used. |\n| **FREESHIP** | free_shipping | Shipping = 0 | No restrictions. Used once. |\n| **EXPIRED20** | percent | 20% off | **Expired** (ended 2024-12-31). Must be rejected at checkout. |\n| **MAXED** | percent | 10% off | **Maxed out** - usage limit 5, used 5. Must be rejected. |\n\nSo 3 working codes, 2 that test error paths. No automatic discounts in seed data (all are code-based).\n\n---\n\n## 2. Order Management Flow\n\nThe spec defines a full order lifecycle. Here's the flow:\n\n**Order Statuses (3 axes):**\n\n| Axis | Values |\n|------|--------|\n| Overall status | pending -> paid -> fulfilled -> cancelled / refunded |\n| Financial status | pending -> authorized -> paid -> partially_refunded / refunded / voided |\n| Fulfillment status | unfulfilled -> partial -> fulfilled |\n\n**Admin can:**\n- View/filter order list by any status axis or date range\n- Open an order detail with full timeline (lines, payments, fulfillments, refunds)\n- **Create fulfillments:** pick line items + quantities, enter carrier + tracking number. Partial fulfillment supported (ship some items now, rest later).\n- **Issue refunds:** full or partial, against a captured payment. Optionally restock inventory.\n\n**The 15 seeded orders cover:** unfulfilled, fully delivered, partially fulfilled, cancelled + full refund, partial refund, pending payment, digital fulfillment, high-value, multi-item, multi-quantity.\n\n---\n\n## 3. Onsite Checkout with Mock PSP\n\nThis is a **change from the current spec**, which redirects to Stripe Checkout Sessions. You want everything onsite with a fake PSP. Let me clarify what I understand and a couple of questions:\n\n**What I understand you want:**\n- No external redirects (no Stripe, no PayPal)\n- Payment method selection step in the checkout (e.g., \"Credit Card\", \"PayPal\", \"Bank Transfer\" - all mocked)\n- Onsite form (e.g., fake card number fields)\n- A two-step payment flow: **pre-authorization** (reserve the amount) then **capture** (actually charge) - mirroring how real PSPs work\n- The mock always succeeds (or can be made to fail for testing)\n\n**The current checkout state machine stays the same:**\n`started -> addressed -> shipping_selected -> payment_pending -> completed`\n\nThe mock PSP would slot in at the `payment_pending -> completed` transition, just faking the auth+capture instead of calling Stripe."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":109,"cache_read_input_tokens":126152,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":109},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VA2sDQuPhA5oexQyZd","type":"assistant","uuid":"8a2786b6-c850-4551-8e94-c4fa4e852a78","timestamp":"2026-02-11T15:04:38.856Z"}
{"parentUuid":"8a2786b6-c850-4551-8e94-c4fa4e852a78","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017sQYeUtW7hCUxgNyDeK36m","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AYRuxeRU2UAz8oAcZAoVGh","name":"AskUserQuestion","input":{"questions":[{"question":"Which mock payment methods should appear in the checkout payment selection?","header":"Pay methods","options":[{"label":"Card + PayPal + Bank Transfer","description":"Three options to showcase a realistic payment selection UI"},{"label":"Card + PayPal","description":"Two options, simpler"},{"label":"Card only","description":"Single mock credit card form, simplest"}],"multiSelect":false},{"question":"Should the mock PSP support triggerable failures for testing (e.g., a magic card number that fails)?","header":"Fail testing","options":[{"label":"Yes, magic numbers","description":"E.g. card ending 0000 = decline, 1111 = success. Useful for E2E tests."},{"label":"Always succeed","description":"Mock always authorizes + captures. Simpler, failures tested via unit tests only."}],"multiSelect":false},{"question":"When should capture happen relative to authorization?","header":"Capture timing","options":[{"label":"Auto-capture on checkout","description":"Auth + capture in one step at checkout completion (like Stripe Checkout). Simpler."},{"label":"Manual capture by admin","description":"Auth at checkout, admin manually captures later from the order detail page. More realistic but more UI work."}],"multiSelect":false}]}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":109,"cache_read_input_tokens":126152,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":109},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VA2sDQuPhA5oexQyZd","type":"assistant","uuid":"e5aebe17-ffb1-4922-9c62-4ce6b6b971ba","timestamp":"2026-02-11T15:04:39.017Z"}
{"type":"queue-operation","operation":"remove","timestamp":"2026-02-11T15:05:23.284Z","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b"}
{"parentUuid":"e5aebe17-ffb1-4922-9c62-4ce6b6b971ba","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"User has answered your questions: \"Which mock payment methods should appear in the checkout payment selection?\"=\"Card + PayPal + Bank Transfer\", \"Should the mock PSP support triggerable failures for testing (e.g., a magic card number that fails)?\"=\"Yes, magic numbers\", \"When should capture happen relative to authorization?\"=\"Auto-capture on checkout\". You can now continue with the user's answers in mind.","tool_use_id":"toolu_01AYRuxeRU2UAz8oAcZAoVGh"}]},"uuid":"d56ea781-0bf7-43d6-a5db-11631addffdf","timestamp":"2026-02-11T15:05:23.280Z","toolUseResult":{"questions":[{"question":"Which mock payment methods should appear in the checkout payment selection?","header":"Pay methods","options":[{"label":"Card + PayPal + Bank Transfer","description":"Three options to showcase a realistic payment selection UI"},{"label":"Card + PayPal","description":"Two options, simpler"},{"label":"Card only","description":"Single mock credit card form, simplest"}],"multiSelect":false},{"question":"Should the mock PSP support triggerable failures for testing (e.g., a magic card number that fails)?","header":"Fail testing","options":[{"label":"Yes, magic numbers","description":"E.g. card ending 0000 = decline, 1111 = success. Useful for E2E tests."},{"label":"Always succeed","description":"Mock always authorizes + captures. Simpler, failures tested via unit tests only."}],"multiSelect":false},{"question":"When should capture happen relative to authorization?","header":"Capture timing","options":[{"label":"Auto-capture on checkout","description":"Auth + capture in one step at checkout completion (like Stripe Checkout). Simpler."},{"label":"Manual capture by admin","description":"Auth at checkout, admin manually captures later from the order detail page. More realistic but more UI work."}],"multiSelect":false}],"answers":{"Which mock payment methods should appear in the checkout payment selection?":"Card + PayPal + Bank Transfer","Should the mock PSP support triggerable failures for testing (e.g., a magic card number that fails)?":"Yes, magic numbers","When should capture happen relative to authorization?":"Auto-capture on checkout"}},"sourceToolAssistantUUID":"e5aebe17-ffb1-4922-9c62-4ce6b6b971ba"}
{"parentUuid":"d56ea781-0bf7-43d6-a5db-11631addffdf","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CfqpTDcDz5ehajWByricx7","type":"message","role":"assistant","content":[{"type":"text","text":"Got it, I won't touch the specs. Here's the summary of what we agreed on:\n\n### Onsite Checkout - Mock PSP Design\n\n**Payment methods in checkout (3 options):**\n\n| Method | Mock UI | Mock behavior |\n|--------|---------|---------------|\n| **Credit Card** | Fake card number, expiry, CVC fields | Validates format, uses magic numbers for outcomes |\n| **PayPal** | \"Pay with PayPal\" button (no redirect) | Simulates instant approval, no external flow |\n| **Bank Transfer** | Shows fake bank details / confirmation button | Simulates instant confirmation |\n\n**Magic card numbers for testing:**\n\n| Card Number | Outcome |\n|-------------|---------|\n| `4242 4242 4242 4242` | Success - authorized + captured |\n| `4000 0000 0000 0002` | Decline - payment fails |\n| `4000 0000 0000 9995` | Insufficient funds |\n| Any other valid format | Success |\n\nPayPal and Bank Transfer always succeed (no failure trigger needed since there's no real input to vary).\n\n**Capture timing:** Auto-capture at checkout completion. Auth + capture happen as a single step. The payment record goes straight from `requires_action -> captured`. Admin sees the payment as captured on the order detail page but does not need to manually capture.\n\n**No real PSP integration at all.** The `PaymentProvider` interface gets a `MockPaymentProvider` implementation that handles all three methods, checks magic numbers, and returns success/failure synchronously. No webhooks, no external API calls, no Stripe SDK.\n\nWant me to proceed with planning the implementation, or do you have more questions first?"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1702,"cache_read_input_tokens":126261,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1702},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VG6xgxnJGLkfw1rv4S","type":"assistant","uuid":"8b9ae0ab-07e5-4c95-b90d-415c4b6e91ec","timestamp":"2026-02-11T15:05:35.113Z"}
{"parentUuid":"8b9ae0ab-07e5-4c95-b90d-415c4b6e91ec","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"Stop","hookName":"Stop","command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"},"parentToolUseID":"ce0f2b07-3eb5-492e-9911-e7792958faaf","toolUseID":"ce0f2b07-3eb5-492e-9911-e7792958faaf","timestamp":"2026-02-11T15:05:35.145Z","uuid":"651943eb-4588-433a-b6ca-72ccb4108986"}
{"parentUuid":"651943eb-4588-433a-b6ca-72ccb4108986","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-02-11T15:05:35.171Z","uuid":"7f105f1b-a961-4659-931c-cf5d7c7af0a0","toolUseID":"ce0f2b07-3eb5-492e-9911-e7792958faaf"}
{"parentUuid":"7f105f1b-a961-4659-931c-cf5d7c7af0a0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"turn_duration","durationMs":49950,"timestamp":"2026-02-11T15:05:35.177Z","uuid":"4b458225-efb5-46ba-ad92-91d70ae52d1f","isMeta":false}
{"type":"file-history-snapshot","messageId":"1a0f8b3e-2c58-48ad-b00c-bbc3935793ee","snapshot":{"messageId":"1a0f8b3e-2c58-48ad-b00c-bbc3935793ee","trackedFileBackups":{},"timestamp":"2026-02-11T15:05:46.122Z"},"isSnapshotUpdate":false}
{"parentUuid":"4b458225-efb5-46ba-ad92-91d70ae52d1f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"OMS workflow per payment method and shipment method is a bit different. do we support this?"},"uuid":"1a0f8b3e-2c58-48ad-b00c-bbc3935793ee","timestamp":"2026-02-11T15:05:46.122Z","thinkingMetadata":{"maxThinkingTokens":31999},"permissionMode":"acceptEdits"}
{"parentUuid":"1a0f8b3e-2c58-48ad-b00c-bbc3935793ee","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01U2mr2tcF4dfkbevmi2rh1i","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":416,"cache_read_input_tokens":127963,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":416},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VHnm7Za9B5tg7DuvCg","type":"assistant","uuid":"827bbb76-f1b1-41dd-9301-830fae5b3f7d","timestamp":"2026-02-11T15:05:48.770Z"}
{"parentUuid":"827bbb76-f1b1-41dd-9301-830fae5b3f7d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01U2mr2tcF4dfkbevmi2rh1i","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking whether the OMS (Order Management System) workflow differs per payment method and shipment method. Let me think about what the current spec says and what the real-world expectations are.\n\nIn real e-commerce:\n\n**Per payment method differences:**\n- **Credit Card:** Auth at checkout, auto-capture -> order is \"paid\" immediately. Fulfillment can start right away.\n- **PayPal:** Similar to credit card - payment confirmed instantly. Order is \"paid.\"\n- **Bank Transfer:** Order is created but payment is NOT confirmed yet. Order sits in \"pending\" / \"awaiting payment\" state. Admin needs to manually confirm payment receipt (or an automated bank reconciliation does it). Only then can fulfillment begin.\n","signature":"EqIUCkYICxgCKkAxxLl+0TEkqpyJAEQMCWvm4UtWmoC7/q5K3FzcdV7sqr9WcDSu5aP6/65+sr2+u85WeTuQJe80Y2MLg0DoaRweEgxYCVYXt0DLEN/+RJcaDNOhXWv/GMTnPot6PCIwH8E+hmMxZF9XZekMuSzWHk44Cj4XJ7KqKQKYv+xDdtgGfyZ0LrdnAFwa5wktJb9MKokT/g9zQ10OMJw8onRZlycPAhde3mvavCy3puzXfErX9qNup7w2SjuvVujLVHS3A9VLuTGyc49f1tX5Ti+8X48pNbZegEPbJspD/8jOaIkRvTzDm9qsfDvF31ecS8/I1G3aQL3JgdjBtab9D+wwRL6k9YRBZHWgKq/Z9U7eBD+aXcovkN69wtCiMrfkSm3T5xVw8LnflSoxqJtR9G4w5YoAnP9fgJnO5CB8Gt4D5vjPmIHpoOCQQjTseutzTh5Gou3M/QBa6mHL7BG1Sy70PKWp7W+eR5vfZTYID+lDWF8XhrF/KmDKt7fJrId94lhxG8Jr7yAgcVZ5XRtKOJ281pKj+Xwlp3Uz3XXhr6RCm0ka6BhRTwtZcvZ74kHIaa5X+UCQsKoNTztT+ZfT5icyyW4lhj5AdwW6BZYJ+7tdhc6c/hXCLa3S6Gle4Yjznx51ELKQPvcqND+a8s8VGetjsPZJVsdrBb6pRHfMdHS/d88eg04qmVyJ9vouUg9qufP2zKQBl2HkKjpvLSgvK6sVdNB1BsLkF5eRciGrJqT2LA7CCr3JDFjhESWZSSiVrvoye0TGJhBNnL2mkS5guOvg48hOGuKg+kx9CugMts51tzeXvBJcn4gEfQ7kbbb7sb3/Ir5zy+0OtfE/7FoKQWPpcjncl6nJgaKbXNyboh2+lPCJsp7GV4kKImSq48KGJTOuXTBMC7CJ5xMSsbZtBiZAxG/kBZ7c9fR7T3kiuqpreeucG3q0BkCKO2erRWECoV2ap2ihcCHEXhDjGT2y/z1kIyE3VzpXn5AN4shbkHzrYBFHj3ZA+UNha4PelIjSNP1/QJif1bXSlqbpLmKVM+8pBwHK08uPxbB7Hz1qr3KWOTAh4ojsC4+B3qusgMhGxz3afUAHQV8Gd8aYcIK9f3KkTCOcNev3A1X3+5ZCDjlxNgKwtF9hLcmjzAaV4jExSembbiXTQuNKgGlGhyVUFgpp+9d6SrfDD6EujZJvAt+Bfvs85WJs3DfR/0+rB3QZBZ/780+l1UoUWPV1fRtRDKc24xJ5ZTMYrrBbvowYsjaonahdXZZjgfJggmP8UUdoSzSPFODKbCjrutEpAnL4qWGqP4VSstg/kDpLXBKY+PCaLaJaau201c0BiZ5jRG0/Jr+0IhGmIWKhI9UsbbSK+BkH5KSRuFzoSKuKysXujq2gvHFG70cf/dgzqBz083ai086zTXfhmksGVqbLAzc406G/VJGzTJQU4gpWeTGv67jHGqhwvaMHJ4RxMOl8YrE1BFf32oi/M1TEgG3+LsUzJoOAWRrK6rR3HganChbDmLanTPDI/XpeWLK1bLR2kSZ3VbMpaFQ3itgpJZqY2mmlj6W8HiADzJ9ZYpCrkQSDn6NXJSv6oaYylnY18yolrI+8bvKyJQfGeh0zYYGxvvuMbfNJ0M1GEvzoaX502UfVmF9yE67le30rqHcp1B+jWIW6Sxinqx4UzFso62Ijo7BwPHxDPe9r4uPCYthD+45k4fkRv/dcMOfcfIV8E9M7SPo4w9s0mcrhXgRnEjVzwiHuXlzGJlG8kzKvqH/GnOuD12mGrWV1Qdg8R2kfrbDuG7snxoaUNVvpiHqwUBUr4TPsOVUlhzq74J+eCH09P1epzC4C1Fm7hzdDVWvFiz9GkwbPJMk3k9ND/PcvlKf9rZfCSnVI0jFTkDgadAXhB/SALh3CDRwCFXcp0mWQf1zFmOyPd5MWFNc87Y+dWHxeqbr3ZlE5+yPsWcFoZSlkXrlG/OypGf6yEj5PAhTt7n9U95NZ2HM9aKtU8ed5sbVoqjTynBwrErOAtD5wQyM/QxqaAdGdlXAXXHGMHv4f9QgqX4sz68TEbONjFicQVlOrRhO/b7U4DUFHu7f1QneMA+ic0rtpjIfbZcHKD/2w4cXXMbHIPVg+rP9QNnU4qghjyrqMtGSvFEGhXOLAS3K8uHhp3b5jp0rEmyr9c0w7ZLeLUXhrBKI85SF0p71+qIeftMy3BX/n+J8NgLVWwMZfgzJoua88lcLO33UBmnqxoGQWyM/KpEFYYplwsuD0HQOIa/MYSVR556onGIbscxY2/T/3LmxetudauE/ZQxYFvvrjBKV/AwIIen8cIkrTSvT+OftRrDxj8SF8ClE85sqIF4/pYKGqjX3hu2K2mX5r4pW59NsOZvmkO6tz6kGWrczwVhV3UAIRbPpZViCvaz/NoCuK9/EoDO4fj6r7frkLXKSF1pc4pEVxzZrDQCzbUlouJcdSorg3fMMOf943gpIxBtmrpbBz46cDzx/X6FOFTr5zYNWI5ngkfqQ+TW9YxL7gLYBPM5EUiKc0j5nOufAwMLj58nDALGRu/MRqc1hDNMj7z++1VaToUVox4mxB94LamTKoapdqHLDw6zrGxe6hve/ehYyaUDoC48Wht6Xv7gJ5GHnM6hmyh5iAlfE7qVwnOPY9pXQ5mc/2Db9cmAOq/wd+NIVp2fU7TmgqBXjhgabxAxwgXUGWypeGi++LRtRYe//d07xdhWmDxdJDBxQyUYVPNF3WC+P6hZMPYqC9s3BUVCvBGKwKVQX6cPRmxiPpondbSMB7Qnc9g/TYHiDRap3lN65WTVpsNxvh3nXpDlL+PkkXhm3go5+akLIdcXZFhTKcpf96Q0oHWT0fLVy02NHTVkx10Er81jGwwJwuNjuYng77kjNNIc+3D7ju4Dw+UfIOMO3VGyeaNUjxc+zGYa7yMJK/Be7/PPb1NquW+zHngCDGDH22QipKoY/Xc8F4N+K1w9mSLTFExoeDmkRGmj07xvo7tlYsoxLBqyXxwffAB8yXeXgtsbzPzL6no9b+y7HNq+7mhfOfdAPT2GyG6wmswy03cNJtMm/aPoeWcaMa9FqTMn7a26YXXuRieNMTFUBh4TSMs3p2f/dL8BkeEOTdk9LN9faWaTsp1d3uKCM+7ki8Himri0uquipqOxiQ6+nzLNqZQh3OW6wBv/vuDs5waPrfokTrsasHegyTmsbivAsXZySCZnTGUjBTePOkWBEP5iPB6UOlUga43ndisWyfwnyj0s63xlHYiOTJy5v0DnokFE4vaXJgRDaQMKRol9gihGqsRtjdjzTMU37N3fHRPJCtOWuOz1e1W2MQycmXsxAVdUNDn57hdRpSh31i/v40SULKPsWMFJNDyXgc7odc8MCk1a5i/hozBb4vxuGg7Jng0Ln9ld4G/kT2dp88gvw7wI/Q25KDCfmGZDjWgbySqDddAj0k/TnsR1uqDp989TsYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":416,"cache_read_input_tokens":127963,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":416},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VHnm7Za9B5tg7DuvCg","type":"assistant","uuid":"cbf628ba-5645-47e1-8503-4608b8c64519","timestamp":"2026-02-11T15:06:36.038Z"}
{"parentUuid":"cbf628ba-5645-47e1-8503-4608b8c64519","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01U2mr2tcF4dfkbevmi2rh1i","type":"message","role":"assistant","content":[{"type":"text","text":"Good question. Looking at what the spec currently defines - **no, it does not differentiate OMS workflow by payment method.** The current flow assumes every payment confirms instantly (the Stripe redirect model). That breaks with Bank Transfer.\n\n### The Problem\n\nThe current `completeCheckout` procedure does this in one step:\n\n```\nPayment confirmed -> Create order (status=paid) -> Commit inventory -> Done\n```\n\nThat works for **Card** and **PayPal** (instant confirmation), but **Bank Transfer** needs:\n\n```\nOrder created (status=pending, financial=pending) \n  -> Customer transfers money offline\n  -> Admin manually confirms payment receipt\n  -> Order becomes \"paid\"\n  -> Fulfillment can begin\n```\n\n### What differs per payment method today vs. what should differ\n\n| Concern | Card / PayPal (instant) | Bank Transfer (deferred) |\n|---------|------------------------|--------------------------|\n| Order created when? | On payment confirmation | On checkout completion (before payment) |\n| Initial financial_status | `paid` | `pending` |\n| Inventory reserved or committed? | Committed immediately | **Reserved** until payment confirmed, with a timeout |\n| Admin action needed? | None | Must confirm payment receipt |\n| Fulfillment allowed? | Immediately | Only after payment confirmed |\n| Auto-cancel on non-payment? | N/A | After X days (configurable) |\n\n### What differs per shipment method\n\nThe spec already handles digital vs. physical (Gift Card skips shipping). But it doesn't distinguish further:\n\n| Concern | Physical (Standard/Express) | Digital (no shipping) |\n|---------|----------------------------|----------------------|\n| Fulfillment flow | Pick, pack, ship, enter tracking | Auto-fulfill on payment (email delivery) |\n| Shipping step in checkout | Yes - zone matching, rate selection | Skipped entirely |\n| Fulfillment status after payment | `unfulfilled` | Could auto-set to `fulfilled` |\n\n**Currently missing:** digital products are not auto-fulfilled in the spec. The seeded Gift Card order (#1014) is manually marked as fulfilled. In a real shop, digital delivery would trigger automatically.\n\n### Summary\n\nThe spec currently treats all payments as instant and all fulfillments as manual. To properly support the 3 payment methods, we need:\n\n1. **A \"pending payment\" order state** for bank transfer (order exists, not yet paid, inventory reserved with timeout)\n2. **Admin \"confirm payment\" action** on order detail page\n3. **Auto-cancel job** for unpaid bank transfer orders after N days\n4. **Auto-fulfillment** for digital-only orders on payment confirmation\n\nDo you want me to account for these in the implementation plan, or do you want to keep it simpler (e.g., drop bank transfer, or treat it as \"always instant\" like the others)?"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":416,"cache_read_input_tokens":127963,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":416},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VHnm7Za9B5tg7DuvCg","type":"assistant","uuid":"4ddf1176-beaa-4c3b-aa68-90bbd78ed723","timestamp":"2026-02-11T15:06:39.323Z"}
{"parentUuid":"4ddf1176-beaa-4c3b-aa68-90bbd78ed723","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"Stop","hookName":"Stop","command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"},"parentToolUseID":"a4909898-d4fe-4490-a7bc-347502cd25cb","toolUseID":"a4909898-d4fe-4490-a7bc-347502cd25cb","timestamp":"2026-02-11T15:06:39.615Z","uuid":"c4689d6d-1714-4de9-9324-1646c7f294db"}
{"parentUuid":"c4689d6d-1714-4de9-9324-1646c7f294db","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-02-11T15:06:39.630Z","uuid":"f94a3348-6810-45c6-a318-a16e1f29e6f6","toolUseID":"a4909898-d4fe-4490-a7bc-347502cd25cb"}
{"parentUuid":"f94a3348-6810-45c6-a318-a16e1f29e6f6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"turn_duration","durationMs":53509,"timestamp":"2026-02-11T15:06:39.635Z","uuid":"f2767e92-9539-4982-9036-a75c657fce56","isMeta":false}
{"type":"file-history-snapshot","messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":false}
{"parentUuid":"f2767e92-9539-4982-9036-a75c657fce56","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)"},"uuid":"e438c861-f6cb-4565-97e0-d362dcaf336c","timestamp":"2026-02-11T15:07:06.415Z","thinkingMetadata":{"maxThinkingTokens":31999},"permissionMode":"acceptEdits"}
{"parentUuid":"e438c861-f6cb-4565-97e0-d362dcaf336c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014AL67BJ47rij3sy9c6L3Jo","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":25,"cache_read_input_tokens":129582,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VPhvKkqKLnVooLf1LB","type":"assistant","uuid":"679a5a76-a7e3-4d8b-94a2-573cbd159fbf","timestamp":"2026-02-11T15:07:08.765Z"}
{"type":"queue-operation","operation":"enqueue","timestamp":"2026-02-11T15:07:31.074Z","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","content":"Events (like \"shipped\") are triggerd by admin buttnos"}
{"parentUuid":"679a5a76-a7e3-4d8b-94a2-573cbd159fbf","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014AL67BJ47rij3sy9c6L3Jo","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to adjust the specs based on our discussion. Let me summarize the changes needed:\n\n1. **Onsite checkout with mock PSP** - Replace Stripe redirect / PayPal stub with:\n   - 3 payment methods: Credit Card, PayPal, Bank Transfer (all mocked)\n   - Magic card numbers for test failures\n   - Auto-capture on checkout for Card/PayPal\n   - Deferred payment flow for Bank Transfer\n\n2. **OMS workflow differentiation per payment method:**\n   - Card/PayPal: instant auth+capture, order created as \"paid\", fulfillment allowed immediately\n   - Bank Transfer: order created as \"pending\", inventory reserved with timeout, admin confirms payment, then fulfillment allowed\n   - Auto-cancel job for unpaid bank transfer orders\n\n3. **OMS workflow differentiation per shipment method:**\n   - Physical: manual fulfillment (pick, pack, ship, tracking)\n   - Digital: auto-fulfillment on payment confirmation\n\nNow I'm mapping out which spec files need changes and planning the scope. The main files are 05-BUSINESS-LOGIC.md (payment processing and OMS workflows), 02-API-ROUTES.md (checkout and admin payment confirmation endpoints), 03-ADMIN-UI.md (order detail page with confirm payment button), 04-STOREFRONT-UI.md (payment method selection and onsite forms), plus updates to database schema, seeders, E2E tests, and the implementation roadmap.\n\nBefore making edits, I need to think through the design decisions: payment method enum values, database columns for tracking payment method on checkouts and orders, how payment statuses flow differently per method, order financial status transitions, inventory reservation mechanics with timeout, and the auto-cancel job logic. I also need to clarify that fulfillment is blocked while financial_status is pending, and digital orders auto-fulfill once paid.\n\nI should read the full relevant sections of the files I'm modifying to understand the current structure before making changes, particularly the payment processing section in 05-BUSINESS-LOGIC.md and the order/fulfillment management sections that follow it.","signature":"EoA5CkYICxgCKkD8k+TSwegz0wNXcYQLm1wLJxqRXCfzBFbnDwLkn9MnFSmZm7SL0gXfeAcRepZwTtMH+EYW7AkWWKFsq/MtH7P2Egwhe67D+nNJRYH28iYaDNDr3jmTQKdMQ72u4iIwT1pP8qmeyeTU4dMh6mvKc+0LBPVoMZ/+96zVlzYlqV8GMLDDR+U+WjWKeVbqzTfFKuc3H3dK/RZXOUNIzlQrPpfue8RQF5haDOIsjOm1B/WUb+eZwg6aArRpnbbF01oNa4KMC4PPscxaeYJNhpsz+sOfPyRYioT+4tly1Fqh68TVlTEhRLw7ORCMRhKRcClxAaYpUJZMgfOZErqFmQvN5dO4ZhfORnRndFdYX2ExR9nHRE1ZXr08QyY2+r+byhSkFkncz5D9idgf1/8TxLPHyyXK1VcnRRvzM/s20bhzKccLIhdPuW0cWXLDmlu5PiF3WGZQAhJaO+3fWmJdtP2kp3v0DjpNe7uEYIKfYW4AZqyf+YBD9OLKZSm+AgmpbVUjIxn3/wV3QcXhzZapu6E4kMwEvGo4Sv2FnkUjf/+E4JVIVi40zdTFWAovWD2jisxqHkRvxcJXIyrO0TkdAT9zNAd2yrqlSiujU9u3M4MWgf1+/ASSVEoSTlTnnqtF0TuxTE32s0Lmz+8U+dTOwQKCugceOd+cdkVfskWLj3WcFnq2mNTFGuhE1ERBAp+faVHhvfyZUyIzN1mv+IhvPgBFskwJgruLCXm+YHViQJ5p4nT1gmZjnlcQHw7ky85j9Q14K9rhhLZkp/BRYskCOXxlnHtwkAGOetzuebOBaoihDWO/32JhnV0XqcfSZawHzd1Ne7sftC2h4Hbm4jbVyXHK1ZX4Y2fbmCY3DiL69+k434Zc+HyLLppmUGjaHjt5XJHwL2HRQq2SAHfSMeD0kcHoCTyXxqaeleT2toWHxpsQB2KavZgG2ZFbDEMwSJM926PsubBb/Ok8+YxFf8TDIO8IQ4guNKqa7vc0QY/O7nYSXfxANRwEb0DKqDLnuNdtOVPpFgYL57k78C6bu6Vjbn4NA7PraG24G/Uq3i/qA/6hYPR9M0F3luJU7yCpUCTy1lp1gmGvk0CnBUtI0QQz00KExFRBZJirL6Zb/hnh7jCm3Mn0kHD8P5sTD3VsX/S2d6GdHrMbKfEeEuxFRGr6voKZKarpwYDBUlGrQ+++fHEuONm0UFlbOu7ECupF4VGNPj9SMsD/xFD4kvLbtP3T6mkFqhi6lMjPVxohdRIvlyVUfMBulx+HBFc4z5MMH/BEFGVvmv5js6ZXyL/rt4FCDuPdSFqjvzjd7qq1RrdBXtXoGp9ErifMBkOq11Bj0lGp8ELvKa1XMY9BUG05q6DcHlWgnQKxLfX1sD9VBhybj+GEjCvUehHOIlggubYTlucgA9ac2VwQvwkIIgC3l002M5bWsi3SVapy1wVXtplHv4v/gxtl3qglnnNUmgDSnTAJmTMN6G9dXlwh0YeuIogNRbhUfFPZiJoVXR6Wzufl47cVmPLl2gV9dhJmKtv1QuRR4+Bq/4t46rua8fGjEw2d/XJWH/+4EhxdkotZnfLFIfAa4fWh73E3P/OLe37PJMNzWImbq8WCYgIZN1vxtXvxRfJImcLXPyOyffNhfWO48w23FmCNpmE0UaSzi8GOVHuJXmQ9FAYqz0tm/mGLl/TyWOeYOHE9FKswp8qQ7PsSYGYhvwmcGggd39P5KMzZ6rw3lre2ekTJbibKHyJAJVu0cBySeup16cQUsa6Nlr4x9SBzXnbH3mmtt92DYjQiufHREVBdcSx8reMbScBNI8QJXEmLEa5gDd7djPoud57MxGXd16Bv3L6IFrZoc0td1UIbK117C2ttmjp2ul1pFRsYyB1b4Ovgcvi+K1+S8yVa0pFH4asE5eZa8+qU90tZXIOazokiIaYek2DRhSD5sk3RXFfieR2HV6cAK39zMmmcD7o30SdbuoJlKUiA27HtNn96bf98sVsjaY5yMxyjsqmS8EcIpJgzz2YuYVfQOfAiAhLES4vW2tUYeX0D2EPtNGsMXswKypvgvB3xBf17grkvjiPdaIn/iKP6K7k0VTqKoxCTrJuMjj8+qQB9dSVAmqAd9A3JZlDrQgRvr54gb28Vj9yj2N++enEZE+h+njqBnft5P1QGWRD+SwtX/CmhDHiRWnrTDS00OkkDy5351HwkjkVUmGVIKk1b2pdoqQ8eoc7dGx6MvGopDQf5oAFvDguhdvSsOWgMfWvC9pHJRo5BpuWPKECnq7yQTYYzIy2WCTprVN0Nxj54fRffWzOWhJ+5gZ0MJLVzBYCFkMqK44BTrfqLAFyPmJHe0CeUbEWQgwS0mOr8Lzago/6eskDJXxx/+LrHiirIqM2k3eGPUj4iDUJw1tixSne0LxHLnocxJxWivpYLmKLdwEjdndPMZMrT4BqP4RbyHYlj7OMUSEESW0u3J26wIGbkvB6tA/IIM7qdyTiUlM9lOZFJShKSfGJSoAzyMrTffIkdKLc+deOQfzVnwmG6GNH1+HhKGvmg9C9NO4tAvWk8FNArPrp6+1g+F8fpnhsAIV832rfWPUJHBMQ3NZpZ7aacNiHv1wRkEjlZICui4zu6FcgJXLLEjLNurRY7SvRK6HE2t2uIIf8moUi41gJcWyKzh4OkpzX7MNq/k45DScu5d6wjV1ky9kfc3Tdk77zmirh069zTB4VkQqiNPa9ww+WMJHF/WQZ24FuKBzkuT9A4JEIb8jc66PGC0Hk8gdozGjQu7s1lgaC+4gOl4LoQX/IF6F1ZpQf/N3aOyoA8PXR0oCBwMnJWnph97T46SqMMWqDNqbLqv1dSSbhaHdp5XSQnCM6vXTR7P8l2jWryKk3wuZdUOKnGMUpL73L8uBBn7n08Luf//C+VrLe2QIbSzWpCOZOWILNpaezilJMCb00mZVbrF429M+CymUXjiNIAtdFrffPvGCXF9oEAyKtzaWuXZITI0JdEAAyYWLJxf6gkxuW5iGrOa+UXu/ZjNrLQWygrxv5lzUo4pQ7a7IsCCN/K/G/rjCiwg+XXrWrcuJ3LkQ5fw7n0B3cWgOjrtRo257lI8xV2RBQF66aAo4wB1GvQRDY3MwBcUtvInRdCnj1qR3NH6NuUQlViC1lGmU23wjY4nO7E9qcKgAygf7XrL/DCqafiYp4v98BQJGf36txSq3QDPt8Df0TkeADoIiHAvHlBQqa4svzE9X5TkQir9ZgrpRsaBH8faaP40V1Zmfh6DDqPdh4CXPKXrdeV1GvEDVy+4nZfsizlhins5AYy1NjUd14d6e5MzGYJOcevxt9TTXIxmCVez5kJl/45AYQYtxtdAUsuJcXrnM5iYeEjyJWHc3Tc4vyC8XScgUCXUUfN4tEVWcv2Q+erBmsT9LkiyYCK/lL2HJfEdup51tyIdplp0eQxqau2upAn0XrOqUCaKaIQHFFT7aYXVcrikW8/ZHGvwmlEJA7fxNj5TfDUfE0nk9uKeQnh3TadHVawbeRwDTo0lKxB1QCFI/cLp2dRntwhor5D80O7R6Q5awDasgngkHRgROnJqH+fyPtXKdDW5Ci5SG+sMBobrfKwMg938fIlw4yJsViJ0yBliEm+BPYXNEYw1aX1uGRKLIpHD1LxzPW0eTOFwzkCGATcw9OQ/PN1Y66KWQrSq4hHq0w7j0D8oVlCB/ZO8isNYZ9Fu+TReLj2ioYuljRdkfH9CGM14gk5lexqsjbg0RzB6kr5SaSXBEeB/Ks0wTOmT0FVIdsAqbBCXl5e9quHv+WM0zr66nTCULhJDOA7R78AxSCRnAUB6qd0jaIQ4KnLGGTsPVAqh7Wq9e4v7cKBoZZ19RZ9B44/bV64tkHm+dNKFQHwCqGBLK4i0Ywdn/jk8Hdut7pbkGofMGOAzaQzJF1DsSyZHmBVPqNlHibhroocPQPa7TDnyf7q5PMgLriTQEg9ngv8b69z374qn4yVJHkqT4Lm+zFP7JS8hxYst8lTBt22Sms90zSt9RfRrY8aQc31R3j9aTbJcqsMwbPEd7adaRnOyxtmyeBlP8VZiFm/+tgnZr3x4H6FG0ZLdfjzSXfF7e9NTGBG2sj7IuaO83oDDdN4i2Pgt1Q8BKsPj3sYQ4Tfec4xgU3XKPeCB2ml547WFpYZfboxBG7oAgbHRs/Hw2P8LvtaqtxQAJHkJ2/sgrqKzWpKhzrmoSSD7wafCq5AKCOaqOTolnl+vdX1mlnT9M1Xfva5PtyhRFuIDDMwjup3kxz5+wJxnnyUunx24sSH79Msxa/iNEr6WeaV+BpvNVBXe1o8C7VcW1A1JQ7CZ3pv1IM7oWGe2ylwK2L7k4fyBr2FniCh4Ja6TcIMT9mZrEKBSKM2iZJmJIToduh+Xyy5gAe5cEYy8YIItqnr1v8YfTpaAMOPc/qpjgDYIvUy2gYpJbJrTLj0zClDP39k6o0rSD3PwXfPuKQBj76yhUCYcQlkZO/ryHNCyBQ5TG+XD5yPwc+wRiIsu2BPeOx8b+OgAoyoJw95JwlEGuX6n2yovogIPbWbmmDGP3MhvTi/pfUyqgBYNzYDljB5WGCISJPSne7fHN8X/9X6gAJl2Bs2PatAHhnZ0qSgMtcC0XRMluPkoBC9SggbtPbhkqJuysfGUm0UyvZZU+bM84c/erdejwBR9t7KeaX4eN8nrRCkH8bXflcZlTmXqlhnQz9iDr7KnVJPLDX1E3tcbQ2MaFSNN3S64at9kP8gSESlw8Yjgq4qluh4FnZOetibGkPcjs7tKPSlDuS3+ci/p4VC/LOHtq70R55Mgb7Ss02ms/6S8AqcaNVabpi7zEQ7HfZ3JVK/0z0XFlEhIdU4O3IFCMwH1Jwbb3RVYZTxCSGVAlUfu/rgfW2rTBwIXSb8+M9wEKque8nySwKmZZ4+tGnh3lo7akhN58Y6xqpghmc/vuOJkeUOqydxj2sfA4UHUBKg8mZACxezxNLdZDV6spKWmGOxsqzAtgeLOllfsXYpbjGt0VcvYeZ4/CvfoZafUE1KRYrgYRhvtBVoeAQ7zTgD7eLFBgs3IeDHlEVD54vSfX8wJW7kfjZ14sCNkEO8UJKcYW/KcDDALMZHn8r4F78dkq63N5UmqQxxYQvaZLV3Qow9Qt2myorxskS4cZvie2Yk/0cMz3BkcQIs9Sr84tU3DoCmmB4qyuZPX82oIwjZPIT7PqmLu2heb1L/R/2cMpUoEi+G03xLYltojqM8K8Y+eRkRd+mSeauZCvEowY++2gwJXQ4U8VUQ84FOfkc/t9+R3wXeKPVOHyCFH0wRjnF/oi3UCeeKODnox+c/z/rcd3wy1CV8BXVEhgwj36bzICQ7W65aC4cFlL2WUyi9ypdiqA+sKfiKYPt3ZrzzlpFwkE/5MBCugtJafoa5ETiYA416EeUqUQWdUHehAi8NlGyCF9T/pxkWjBgXpR2Vsu+/bgUcfdsfQ1XkjVPDtsvaDKt7HSGqyVa/jjx2WNsgKv30BffpGc9nOt1z74QNnXPIIqdtcPk4EKcAqKnNbSn9OHaH3L7ox5l48rt7BYnFR4ifRR3NcI7uVP5evE+qmM7B1ZKc1WGQUSG1M0V+ODZPDoYepfSmTC4gzbyOVDt546A9amnFu0RP+9XjVH09T10jzXD22PAmC6DCzBaToAGASi5dbE+jU3fgKG+cZEQ+rmTDpd53Rh5lOfVjoEwxSkC8s4wtcE2poAqKJipN3/F2YnKJp7jKyLkNnS/V9m8nW9zIW/zsxbU2GiEpjH0gChcjXCWdvjSS+Y0dPf9ttPIt4wx0oXhLQXDsBI3yOnvrPssj/NxUUYjbauuBJ38R6N0z6A/awgUgr2SvlpAXZQ9wWSMcPpGLUK5imB+m72lU8oeIQDr6gOZqcRDxOmOw764XMbpYfkR8Kvtt6b+y5zprp1i3rD64OlqSlxzcz7vzXFjoqBe8N/M+X31jXZE4ohKBFzZEMMVCHFHf8saDwEGqnQWgi0lGsPIWuMTTJsAWEd+wDQASrmUhTMBgDX87sQqJN45gwO7OsswcLI04xljyBX7GvlAOJL8SqK94BHKVd3CESLpVGQOC1YDGmM+S0zUMOOO4kRZntEA7tQwqjQu4GcUAztwD/XuM4Ua4ZC5KgHXhvW/+TDFPwjlrzz3ekzzVcHP268hE8LhfHK+RGVD3w659QK16mwVqcqesTY1pMtEsRr+87M/F+agHZn55VMzrcVMPkjXDeRQTT1CEOGIccYI6adjFV8i1NMBC8nCZDd1dMw973aWnMHmm8KMQefamk7fdzUEXnlBlmTp198WSnA72O7QApccrBQRF5nOmDRpLsXGh/qXOTIDk9hIS5LzNO9DDvkfGcuG8orCbs4LyU2ZsCXMBwncXBpcdfM/3cKyphbdBdGY0PxtPKer9aeamTTNGhdj3jzjdNrvAp3NJvVDrEk8kNHoN2VK4dY8N5P7/zIWpueD9HTysfmncMfJReZXDjpa6lECudfKCitdiMt1H1BcckVW6IEzcODxnJohM+C0M9mPAEhwpMKStC/ddQw9DypchIP0yCXNWkQhJ6jKogOB6iraz81iV8p4Zx2S5cngOjbIfzXslowbECi9owsBglx/kQl4riPwLv3q7IFa6Sv/RNYO1g32F+Za5krD2paR7bWKIxOK0qcItzACou6WpSoRRbnI7/25Lx5Scz11Y0GduTLZUWB7YqiihdjnuFW4eYNhWf06TADhQ7t+Dc4jJuWkgjWLELcEZhhMgmu/XgowWWebLi+L1y2WgU1EHJxjf/SEISsElJ5CVWkulJ5loxmDxOHzWkhjSshlduEMwApZAtV31ORriNOB0D9iifbxeI7N+x7GZH2Mr9Rk/wQ0A6wCR0hQRHeHb45fEdO+dgZ5CNuY/xt5JGNCkkWQOVDbfHi5PCAlWI9NDRazPMhCWUL1grMRzVAtnPPrZemXukpaItiwJDZDOxOMInO9p+wUGu9kpdw9iEKmXiTRXVrdKEhdJ+uQnoFkrByTC5L0vQCmZD50xOf4NHRQcZSPHpM2lF5VUhI+ggR74nCbCzeIxiLNa2m91fK98BvRPHnxTBspX5QWwn3dnH7QdqmX4oVCJlNMtZk8DuAEBExLiFWf7zHYAa1s1If0QrVV2JoF2oZwxCfOudjrqB1/GyN1OnbONrPklD/MYJIHR0X2ZrpXNjiMEilgDR272MnydEI/pw06XnKodlwr7OD8MEOzXtl61A01M3y40laWPaZp5QKmKpcMFHnyRUzEXtFYF3xUabTC9nSCZjpMt2xoAt2njDASOj4e9X16Q9MeX7Ur1OUFID7uQQ3Y3HHezL5oWsnIlMZ0YxanVl6h6M0MMIhlQUzLaeg7JIpR1l6sh+EV8KN9vQLDkMhyoulvNu1cTAVK5iCxqa4VWoleZDGBWmyJSKUpXTxfo9KC5+pPy4YkmiBessJdCDFYomhp2X19473Q1HA1pw104pumOasU/Fky4srpSpNeBsaGCKMHoALUYOPuW3eJ8D+eiVzHSE/yY/LKvwPZfzTDqyG/iYbQdtSZHag8kCwoVvJ67PXNirpuFj2XwrjmJoT+CPxM8MxHV427hcn5W4kCFuaNi2YiUVYOhU7emVzNsfljJaPeQSh4nLCw3KstgmHnhHLDQVO9gqVE3FfOWC/hEtQd5y4JxjsgsEfmdkakgpg7M09aTuBGaQNeN8ddrIQfNs1Iza6NApp9SkFOPxvo4s1aVZOyXiNHbb8NujUunWiZ1MVPAafAhtv34sMkkNC+wIw7v4qf0vLeOfs4n7zdtRqX8MPt3GeE1aQsxpQtLco8dh1kVB3LWVr/CveIhxCNfN3xkinLlym7M7EDuFHKlEltbQR1gjOsx57TCDaLlH1qcrwsI3XVDVJQuArb3y2wh+i1IiPCSU/ymC9PmBkXyHWwaL2S2+BOoFstMsoMXghzB9tzDWjTxpzdqaHbCLHwv90IRMebpusPAtk3a6UbCzy7byBhcZHKYXnln3cN2U98AqJuvuaeZpRHxX5CuWGSxSQ0dqNhi9JuLcRIFRwKOXPz4f/AgCPZ0Du6WZ6dqyn0TFLaUeFxCYkO41wLFF8TSswxnyPsROyGegP1y3NLOdh/31B2wa3A0d4ae31xYpJoiZhr2dTXZgrNw/chq/QnCFDWA2/II2bUFNLSyKp2uBUreOjQ0K/B9q5Q1tQB8RAJJ/H4Z6FUPm1zqcQ5ppov1uDFD1jA8EnLKwbG0EAKDJ/lIN65IuCYwcVaaYMOgTItSOTo3hUuGh6Lecx+Y5Kieaq+Hc21kfj6ewzE7FzB6fZIUGtej1bKVQlo4V0MupYzO6vWx+PFOYLlKVf/t6ZBrNx6KFN0mjVGbffxhkwbLOcJp7nNdeO+1lvSIRZ8NDqp7fNbfZyicSaPLCppoIW3gOa1Tkn51sLWTMQWupLXg/Rur6ouXAxiC+EeaJqhJsoe1F87jSDXNsly/X97+H3m4pl+PPWBTk9kwc3dTW4bSxH3Eye6dtBa5a1ozraYVsxOjQ8UHKBzx1SBk2bTBEQYflRtczBSluT16ntMUkL/8TB0cYLHpAI0BTbtXshGz3ukJajFgQ6bP2y5uyryGzhpZ33NZsuSK2BaBJft6zNZnzVkUfoBCer7ASnu2C0zaKlFbv8iMeOCqfE75VtnTn9V6fH2KPwsfvkZptundEv9AKzk0TtA4zrTHF/WvF7XX7YwZd1iOVpOmcHHWqeIvBrhXCTpXkR6ocA+OhhH3YRaYQTu7fNK90tNnQd9eHSPiFZm+o3mHK/YyRyHnAQ0oLRR7OSk6o9BfJDVte90lSk91A6KkIy2KReeI5Nu5UcQR4JSAJpJ3SOHcx0+5yOsH58ytUJpl4AJ2MyO2xBpZoiUqUbJchKxJeBH+b7tWzQ9mxDpgoTrpLvUzNxVcwwPSIdaiaed/xYuE7nGEy7vD05MXYfB706EadWhtoi4ADemPreL4NrjSHvDqv/Hcwu18Cuh4tialO2GB7yX8arw1iUg7/PYjW9YV46c8cQOPs05tQmEgeQt83MyQmjPLjPVFGQgYM2IRaXkTiteaq39gb4LABbqH2Uw5VKd1Fw4TJC2ipTu4OZHEhFMzoYNMSZ5s+mbHSj9oxiTG3idz0+2Cl6/od1qSvNvf3PuV7ZxM+5+ginpHvemNchkwb0v/Pk2TEkBl1uKxVrpIOK5Xaltp7EENRXilOjxjrvu6BMPzeqZ/Dit8SZc3jYkcrfp9OF2wDAtGZsMjmGjzNlTtHTImqwSlHo4q6KeG/5PmtrGCSgWp+nB30C6PrupAFmf1Xwf7iyFsz1M5ObV8wfW6M9nYGFnbGn4yA+PBfPqRhjOsTRuZcMEssrC0NNXUjkwiNCkvixcMODPbLPJYIklO92NWz+5YcIChuFvmagxn/Z0JNXdBNqH2/rVqOeFY6v6q54nrE4zRkE82iUsUq7lIeFV3Ntxjmgez+ATXBFliz/L0pPzMPEQmbLAXWjOSrSwQnGnN/lW3d7pKidvFmA2Y1fAp5y7U2/sZxOzE2cDbv/+2OKa3AqZIUmjsCJMc65rxuJHeV8BCsg0VQ51sGjJBjhJgdjvmlqr2VoPPwTYZkdYYqG/khC31uz2hZeS+twf75fAveiVsm+i8eKdDzFUWs5FFttR5SSEfrSaf2r40dz6XTNe3oEugz4Ui4xDJ8TZxtD7n0Ck4iCU6viFDP1/TKn5QtpfT4J9/4AX78xZVfZLbsbWiP2bXdjGpGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":25,"cache_read_input_tokens":129582,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VPhvKkqKLnVooLf1LB","type":"assistant","uuid":"8759ee18-9975-4a7c-b2f1-ac47c315d7d3","timestamp":"2026-02-11T15:07:59.102Z"}
{"parentUuid":"8759ee18-9975-4a7c-b2f1-ac47c315d7d3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014AL67BJ47rij3sy9c6L3Jo","type":"message","role":"assistant","content":[{"type":"text","text":"I'll need to read the full sections that need updating across multiple spec files. Let me do that in parallel."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":25,"cache_read_input_tokens":129582,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VPhvKkqKLnVooLf1LB","type":"assistant","uuid":"ed387915-fea6-4b40-a2bb-394faafe8060","timestamp":"2026-02-11T15:07:59.210Z"}
{"parentUuid":"ed387915-fea6-4b40-a2bb-394faafe8060","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014AL67BJ47rij3sy9c6L3Jo","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G1jW9CDwNtQDTgaRmYDoSD","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":997,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":25,"cache_read_input_tokens":129582,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VPhvKkqKLnVooLf1LB","type":"assistant","uuid":"f4926470-d427-402c-b46f-dc6e63aef573","timestamp":"2026-02-11T15:07:59.259Z"}
{"parentUuid":"f4926470-d427-402c-b46f-dc6e63aef573","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01G1jW9CDwNtQDTgaRmYDoSD","toolUseID":"toolu_01G1jW9CDwNtQDTgaRmYDoSD","timestamp":"2026-02-11T15:07:59.263Z","uuid":"25aa5198-6cd0-4f26-bad2-59a8418980f4"}
{"parentUuid":"f4926470-d427-402c-b46f-dc6e63aef573","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01G1jW9CDwNtQDTgaRmYDoSD","type":"tool_result","content":"   997→---\n   998→\n   999→## 11. Order Management\n  1000→\n  1001→### 11.1 Order Creation (from Completed Checkout)\n  1002→\n  1003→See Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n  1004→\n  1005→**Key requirements for the order record:**\n  1006→- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n  1007→- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n  1008→- The raw payment provider response must be encrypted before storage (PCI compliance).\n  1009→- The entire operation must be atomic (single database transaction).\n  1010→\n  1011→### 11.2 Order Numbering\n  1012→\n  1013→- Sequential per store, starting at 1001.\n  1014→- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n  1015→- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n  1016→- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n  1017→\n  1018→### 11.3 Order Statuses\n  1019→\n  1020→Three independent status dimensions tracked on each order:\n  1021→\n  1022→**Order status** (`orders.status`):\n  1023→\n  1024→| Status | Description |\n  1025→|---|---|\n  1026→| `pending` | Order created, awaiting processing |\n  1027→| `paid` | Payment confirmed |\n  1028→| `fulfilled` | All items shipped |\n  1029→| `cancelled` | Order cancelled before fulfillment |\n  1030→| `refunded` | Fully refunded |\n  1031→\n  1032→**Financial status** (`orders.financial_status`):\n  1033→\n  1034→| Status | Description |\n  1035→|---|---|\n  1036→| `pending` | Payment not yet confirmed |\n  1037→| `authorized` | Payment authorized but not captured |\n  1038→| `paid` | Payment captured successfully |\n  1039→| `partially_refunded` | Some items or partial amount refunded |\n  1040→| `refunded` | Fully refunded |\n  1041→| `voided` | Authorization voided (no capture occurred) |\n  1042→\n  1043→**Fulfillment status** (`orders.fulfillment_status`):\n  1044→\n  1045→| Status | Description |\n  1046→|---|---|\n  1047→| `unfulfilled` | No items have been shipped |\n  1048→| `partial` | Some items shipped, others pending |\n  1049→| `fulfilled` | All items shipped |\n  1050→\n  1051→### 11.4 Refund Processing\n  1052→\n  1053→**Service:** `App\\Services\\RefundService`\n  1054→\n  1055→**Input (RefundRequest):**\n  1056→\n  1057→| Field | Type | Required | Description |\n  1058→|---|---|---|---|\n  1059→| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n  1060→| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n  1061→| `reason` | string | No | Text reason for the refund. |\n  1062→| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n  1063→\n  1064→**Process:**\n  1065→\n  1066→```\n  1067→PROCEDURE processRefund(order, request):\n  1068→    1. Calculate remaining refundable amount:\n  1069→       refundable = order.total_amount - SUM(existing refunds for this order)\n  1070→\n  1071→    2. IF request.amount is set:\n  1072→           VALIDATE request.amount <= refundable\n  1073→       ELSE IF request.lines is set:\n  1074→           CALCULATE amount from specified lines and quantities\n  1075→       ELSE:\n  1076→           amount = refundable (full refund)\n  1077→\n  1078→    3. CALL PaymentProvider.refund(order's payment, amount)\n  1079→\n  1080→    4. CREATE Refund record with status = 'pending'\n  1081→\n  1082→    5. On provider confirmation:\n  1083→       UPDATE refund status = 'processed'\n  1084→\n  1085→    6. Calculate total refunded for this order:\n  1086→       IF total refunded = order.total_amount:\n  1087→           SET order.financial_status = 'refunded'\n  1088→           SET order.status = 'refunded'\n  1089→       ELSE:\n  1090→           SET order.financial_status = 'partially_refunded'\n  1091→\n  1092→    7. IF request.restock = true:\n  1093→       FOR EACH refunded line and quantity:\n  1094→           CALL InventoryService.restock(variant, quantity)\n  1095→\n  1096→    8. DISPATCH OrderRefunded event\n  1097→```\n  1098→\n  1099→### 11.5 Fulfillment\n  1100→\n  1101→**Service:** `App\\Services\\FulfillmentService`\n  1102→\n  1103→**Creating a fulfillment:**\n  1104→\n  1105→Input:\n  1106→- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n  1107→- `trackingData` - tracking_company, tracking_number, tracking_url\n  1108→\n  1109→**Process:**\n  1110→\n  1111→```\n  1112→PROCEDURE createFulfillment(order, lines, trackingData):\n  1113→    1. FOR EACH line in request:\n  1114→       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n  1115→       unfulfilled = order_line.quantity - fulfilledSoFar\n  1116→       VALIDATE requested quantity <= unfulfilled\n  1117→\n  1118→    2. CREATE Fulfillment record with status = 'pending' and tracking data\n  1119→\n  1120→    3. CREATE fulfillment_lines for each order line and quantity\n  1121→\n  1122→    4. Determine new fulfillment status:\n  1123→       FOR EACH order_line in order:\n  1124→           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n  1125→           IF totalFulfilled < order_line.quantity:\n  1126→               allFulfilled = false\n  1127→\n  1128→       IF allFulfilled:\n  1129→           SET order.fulfillment_status = 'fulfilled'\n  1130→           SET order.status = 'fulfilled'\n  1131→       ELSE:\n  1132→           SET order.fulfillment_status = 'partial'\n  1133→\n  1134→    5. DISPATCH FulfillmentCreated event\n  1135→```\n  1136→\n  1137→**Fulfillment status transitions:**\n  1138→\n  1139→```mermaid\n  1140→stateDiagram-v2\n  1141→    [*] --> pending : Fulfillment created\n  1142→    pending --> shipped : Merchant marks as shipped\n  1143→    shipped --> delivered : Merchant marks as delivered\n  1144→\n  1145→    note right of shipped : Sets shipped_at timestamp, sends tracking email\n  1146→```\n  1147→\n  1148→When transitioning to `shipped`:\n  1149→- Set `shipped_at` timestamp.\n  1150→- Dispatch `FulfillmentShipped` event.\n  1151→- Send email notification to customer with tracking information.\n  1152→\n  1153→### 11.6 Order Export\n  1154→\n  1155→`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n  1156→\n  1157→**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  1158→\n  1159→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  1160→\n  1161→---\n  1162→\n  1163→## 12. Customer Accounts\n  1164→\n  1165→### 12.1 Registration\n  1166→\n  1167→**Service:** `App\\Services\\CustomerService`\n  1168→\n  1169→**Required fields:**\n  1170→\n  1171→| Field | Validation |\n  1172→|---|---|\n  1173→| `name` | Required, string |\n  1174→| `email` | Required, valid email, unique per store |\n  1175→| `password` | Required, minimum 8 characters |\n  1176→| `marketing_opt_in` | Optional, boolean, default false |\n  1177→\n  1178→- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n  1179→- Password hashed using Laravel's Hash facade (bcrypt by default).\n  1180→\n  1181→### 12.2 Authentication\n  1182→\n  1183→**Separate auth guard:** `customer`\n  1184→\n  1185→Requirements:\n  1186→- Configure a dedicated `customer` guard in auth config, using session driver.\n  1187→- Configure a dedicated `customers` provider using the Customer model.\n  1188→- Login: email + password, scoped to the current store.\n  1189→- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n  1190→\n  1191→**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  1192→\n  1193→### 12.3 Data Access\n  1194→\n  1195→- Customers can only view their own orders (scoped by customer_id match).\n  1196→- Customers can only manage their own addresses (scoped by customer_id match).\n  1197→- All customer queries include the StoreScope global scope for tenant isolation.\n  1198→\n  1199→### 12.4 Guest Cart to Customer Linking\n  1200→\n  1201→```\n  1202→PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n  1203→    existingCustomer = find customer by email in store\n  1204→    IF existingCustomer:\n  1205→        LINK order to existingCustomer\n  1206→    ELSE:\n  1207→        CREATE new customer with:\n  1208→            - email = checkoutEmail\n  1209→            - password_hash = null (guest customer)\n  1210→            - store_id = storeId\n  1211→        LINK order to new customer\n  1212→\n  1213→    // Guest customer can later \"claim\" their account\n  1214→    // by setting a password via registration or account-claim flow\n  1215→```\n  1216→\n  1217→---\n  1218→\n  1219→## 13. Webhook Delivery System\n  1220→\n  1221→### 13.1 Subscription Model\n  1222→\n  1223→Stores (or app installations) subscribe to event types via `webhook_subscriptions`.\n  1224→\n  1225→**Supported event types:**\n  1226→\n  1227→| Event Type | Triggered By |\n  1228→|---|---|\n  1229→| `order.created` | OrderCreated event |\n  1230→| `order.paid` | Payment captured successfully |\n  1231→| `order.fulfilled` | All order lines fulfilled |\n  1232→| `order.refunded` | Refund processed |\n  1233→| `product.created` | New product saved |\n  1234→| `product.updated` | Product modified |\n  1235→| `product.deleted` | Product deleted or archived |\n  1236→| `checkout.completed` | Checkout successfully completed |\n  1237→\n  1238→### 13.2 Delivery Process\n  1239→\n  1240→**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n  1241→\n  1242→**Job:** `App\\Jobs\\DeliverWebhook`\n  1243→\n  1244→```mermaid\n  1245→sequenceDiagram\n  1246→    participant DomainEvent\n  1247→    participant Listener\n  1248→    participant Queue\n  1249→    participant Job\n  1250→    participant TargetURL\n  1251→\n  1252→    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n  1253→    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n  1254→    loop For each matching subscription\n  1255→        Listener->>Queue: Dispatch DeliverWebhook job\n  1256→    end\n  1257→    Queue->>Job: Process job\n  1258→    Job->>Job: Serialize event payload as JSON\n  1259→    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n  1260→    Job->>TargetURL: HTTP POST with headers and payload\n  1261→    TargetURL-->>Job: Response\n  1262→    Job->>Job: Record result in webhook_deliveries\n  1263→```\n  1264→\n  1265→**Required HTTP headers on delivery:**\n  1266→\n  1267→| Header | Value |\n  1268→|---|---|\n  1269→| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n  1270→| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n  1271→| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n  1272→| `X-Platform-Timestamp` | Unix timestamp of the event |\n  1273→| `Content-Type` | `application/json` |\n  1274→\n  1275→### 13.3 Retry Logic\n  1276→\n  1277→On non-2xx response or connection timeout:\n  1278→\n  1279→| Attempt | Backoff Delay |\n  1280→|---|---|\n  1281→| 1 (initial) | Immediate |\n  1282→| 2 | 1 minute |\n  1283→| 3 | 5 minutes |\n  1284→| 4 | 30 minutes |\n  1285→| 5 | 2 hours |\n  1286→| 6 (final) | 12 hours |\n  1287→\n  1288→After 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n  1289→\n  1290→Use Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n  1291→\n  1292→### 13.4 Circuit Breaker\n  1293→\n  1294→- Track consecutive failures per subscription (across different events).\n  1295→- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n  1296→- Log a warning and (future) send an admin notification."}]},"uuid":"6ce130f4-372b-40a7-9feb-73ac65aea894","timestamp":"2026-02-11T15:07:59.262Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The raw payment provider response must be encrypted before storage (PCI compliance).\n- The entire operation must be atomic (single database transaction).\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n**Creating a fulfillment:**\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    2. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    3. CREATE fulfillment_lines for each order line and quantity\n\n    4. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    5. DISPATCH FulfillmentCreated event\n```\n\n**Fulfillment status transitions:**\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created\n    pending --> shipped : Merchant marks as shipped\n    shipped --> delivered : Merchant marks as delivered\n\n    note right of shipped : Sets shipped_at timestamp, sends tracking email\n```\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.","numLines":300,"startLine":997,"totalLines":1583}},"sourceToolAssistantUUID":"f4926470-d427-402c-b46f-dc6e63aef573"}
{"parentUuid":"6ce130f4-372b-40a7-9feb-73ac65aea894","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014AL67BJ47rij3sy9c6L3Jo","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014hFBDwyuvQ3GJxyLeWWGND","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":100,"limit":400}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":25,"cache_read_input_tokens":129582,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VPhvKkqKLnVooLf1LB","type":"assistant","uuid":"fff68866-3b55-4d19-b47e-c9fe4a34a95b","timestamp":"2026-02-11T15:07:59.420Z"}
{"parentUuid":"fff68866-3b55-4d19-b47e-c9fe4a34a95b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_014hFBDwyuvQ3GJxyLeWWGND","toolUseID":"toolu_014hFBDwyuvQ3GJxyLeWWGND","timestamp":"2026-02-11T15:07:59.423Z","uuid":"de2acc60-2a71-46ec-9dcc-6908cdccf542"}
{"parentUuid":"fff68866-3b55-4d19-b47e-c9fe4a34a95b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014hFBDwyuvQ3GJxyLeWWGND","type":"tool_result","content":"   100→| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n   101→| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n   102→\n   103→#### Pages\n   104→\n   105→| Method | Path | Description | Component |\n   106→|--------|------|-------------|-----------|\n   107→| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n   108→| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n   109→| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n   110→\n   111→#### Navigation\n   112→\n   113→| Method | Path | Description | Component |\n   114→|--------|------|-------------|-----------|\n   115→| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n   116→\n   117→#### Apps\n   118→\n   119→| Method | Path | Description | Component |\n   120→|--------|------|-------------|-----------|\n   121→| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n   122→| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n   123→\n   124→#### Developers\n   125→\n   126→| Method | Path | Description | Component |\n   127→|--------|------|-------------|-----------|\n   128→| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n   129→\n   130→> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n   131→\n   132→#### Analytics\n   133→\n   134→| Method | Path | Description | Component |\n   135→|--------|------|-------------|-----------|\n   136→| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n   137→\n   138→#### Search Settings\n   139→\n   140→| Method | Path | Description | Component |\n   141→|--------|------|-------------|-----------|\n   142→| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n   143→\n   144→---\n   145→\n   146→### 1.3 Storefront Routes\n   147→\n   148→**Middleware:** `web`, `store.resolve`\n   149→\n   150→These routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n   151→\n   152→| Method | Path | Description | Component / View |\n   153→|--------|------|-------------|------------------|\n   154→| GET | `/` | Home page | `Storefront\\Home` |\n   155→| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n   156→| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n   157→| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n   158→| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n   159→| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n   160→| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n   161→\n   162→#### Customer Account Routes\n   163→\n   164→**Additional middleware:** `auth:customer`\n   165→\n   166→| Method | Path | Description | Component |\n   167→|--------|------|-------------|-----------|\n   168→| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n   169→| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n   170→| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n   171→| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n   172→\n   173→#### Customer Auth Routes (no auth required)\n   174→\n   175→| Method | Path | Description | Component |\n   176→|--------|------|-------------|-----------|\n   177→| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n   178→| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n   179→| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n   180→| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n   181→| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n   182→| POST | `/reset-password` | Reset password | Handled by Livewire action |\n   183→\n   184→---\n   185→\n   186→### 1.4 Checkout Routes\n   187→\n   188→**Middleware:** `web`, `store.resolve`\n   189→\n   190→| Method | Path | Description | Component |\n   191→|--------|------|-------------|-----------|\n   192→| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n   193→| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n   194→\n   195→---\n   196→\n   197→## 2. Storefront REST API\n   198→\n   199→**Prefix:** `/api/storefront/v1`\n   200→**Middleware:** `store.resolve`, `throttle:api.storefront`\n   201→**Content-Type:** `application/json`\n   202→\n   203→These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n   204→\n   205→---\n   206→\n   207→### 2.1 Cart Endpoints\n   208→\n   209→#### POST /api/storefront/v1/carts\n   210→\n   211→Create a new cart.\n   212→\n   213→- **Authentication:** None (guest) or session-based (logged-in customer)\n   214→- **Rate limit:** `api.storefront` (120/min per IP)\n   215→\n   216→**Request body:**\n   217→\n   218→```json\n   219→{\n   220→  \"currency\": \"EUR\"\n   221→}\n   222→```\n   223→\n   224→| Field | Type | Required | Validation |\n   225→|-------|------|----------|------------|\n   226→| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n   227→\n   228→**Success response:** `201 Created`\n   229→\n   230→```json\n   231→{\n   232→  \"id\": 42,\n   233→  \"store_id\": 1,\n   234→  \"customer_id\": null,\n   235→  \"currency\": \"EUR\",\n   236→  \"cart_version\": 1,\n   237→  \"status\": \"active\",\n   238→  \"lines\": [],\n   239→  \"totals\": {\n   240→    \"subtotal\": 0,\n   241→    \"discount\": 0,\n   242→    \"total\": 0,\n   243→    \"currency\": \"EUR\"\n   244→  },\n   245→  \"created_at\": \"2026-02-11T10:00:00Z\",\n   246→  \"updated_at\": \"2026-02-11T10:00:00Z\"\n   247→}\n   248→```\n   249→\n   250→**Error responses:**\n   251→- `422` - Validation error (invalid currency)\n   252→\n   253→---\n   254→\n   255→#### GET /api/storefront/v1/carts/{cartId}\n   256→\n   257→Retrieve a cart with all lines and computed totals.\n   258→\n   259→- **Authentication:** None (cart ID acts as token)\n   260→- **Rate limit:** `api.storefront` (120/min per IP)\n   261→\n   262→**URL parameters:**\n   263→\n   264→| Parameter | Type | Required | Description |\n   265→|-----------|------|----------|-------------|\n   266→| `cartId` | integer | Yes | The cart identifier |\n   267→\n   268→**Success response:** `200 OK`\n   269→\n   270→```json\n   271→{\n   272→  \"id\": 42,\n   273→  \"store_id\": 1,\n   274→  \"customer_id\": null,\n   275→  \"currency\": \"EUR\",\n   276→  \"cart_version\": 3,\n   277→  \"status\": \"active\",\n   278→  \"lines\": [\n   279→    {\n   280→      \"id\": 201,\n   281→      \"variant_id\": 101,\n   282→      \"product_title\": \"Classic T-Shirt\",\n   283→      \"variant_title\": \"Blue / Medium\",\n   284→      \"sku\": \"TSH-BLU-M\",\n   285→      \"quantity\": 2,\n   286→      \"unit_price_amount\": 2500,\n   287→      \"line_subtotal_amount\": 5000,\n   288→      \"line_discount_amount\": 0,\n   289→      \"line_total_amount\": 5000,\n   290→      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n   291→      \"requires_shipping\": true,\n   292→      \"available_quantity\": 50\n   293→    }\n   294→  ],\n   295→  \"totals\": {\n   296→    \"subtotal\": 5000,\n   297→    \"discount\": 0,\n   298→    \"total\": 5000,\n   299→    \"currency\": \"EUR\",\n   300→    \"line_count\": 1,\n   301→    \"item_count\": 2\n   302→  },\n   303→  \"created_at\": \"2026-02-11T10:00:00Z\",\n   304→  \"updated_at\": \"2026-02-11T10:05:00Z\"\n   305→}\n   306→```\n   307→\n   308→**Error responses:**\n   309→- `404` - Cart not found\n   310→\n   311→---\n   312→\n   313→#### POST /api/storefront/v1/carts/{cartId}/lines\n   314→\n   315→Add a line item to the cart.\n   316→\n   317→- **Authentication:** None\n   318→- **Rate limit:** `api.storefront` (120/min per IP)\n   319→\n   320→**Request body:**\n   321→\n   322→```json\n   323→{\n   324→  \"variant_id\": 101,\n   325→  \"quantity\": 2\n   326→}\n   327→```\n   328→\n   329→| Field | Type | Required | Validation |\n   330→|-------|------|----------|------------|\n   331→| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n   332→| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n   333→\n   334→**Success response:** `201 Created`\n   335→\n   336→Returns the full cart object (same schema as GET /carts/{cartId}).\n   337→\n   338→**Error responses:**\n   339→- `404` - Cart not found\n   340→- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n   341→- `422` - Validation error\n   342→\n   343→```json\n   344→{\n   345→  \"message\": \"The given data was invalid.\",\n   346→  \"errors\": {\n   347→    \"variant_id\": [\"The selected variant is out of stock.\"]\n   348→  }\n   349→}\n   350→```\n   351→\n   352→---\n   353→\n   354→#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n   355→\n   356→Update the quantity of a cart line.\n   357→\n   358→- **Authentication:** None\n   359→- **Rate limit:** `api.storefront` (120/min per IP)\n   360→\n   361→**Request body:**\n   362→\n   363→```json\n   364→{\n   365→  \"quantity\": 3,\n   366→  \"cart_version\": 3\n   367→}\n   368→```\n   369→\n   370→| Field | Type | Required | Validation |\n   371→|-------|------|----------|------------|\n   372→| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n   373→| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n   374→\n   375→**Success response:** `200 OK`\n   376→\n   377→Returns the full cart object with updated totals.\n   378→\n   379→**Error responses:**\n   380→- `404` - Cart or line not found\n   381→- `409` - Version conflict\n   382→- `422` - Validation error (e.g., exceeds inventory)\n   383→\n   384→---\n   385→\n   386→#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n   387→\n   388→Remove a line item from the cart.\n   389→\n   390→- **Authentication:** None\n   391→- **Rate limit:** `api.storefront` (120/min per IP)\n   392→\n   393→**Request body:**\n   394→\n   395→```json\n   396→{\n   397→  \"cart_version\": 3\n   398→}\n   399→```\n   400→\n   401→| Field | Type | Required | Validation |\n   402→|-------|------|----------|------------|\n   403→| `cart_version` | integer | Yes | Must match current cart version. |\n   404→\n   405→**Success response:** `200 OK`\n   406→\n   407→Returns the full cart object.\n   408→\n   409→**Error responses:**\n   410→- `404` - Cart or line not found\n   411→- `409` - Version conflict\n   412→\n   413→---\n   414→\n   415→### 2.2 Checkout Endpoints\n   416→\n   417→#### Checkout Flow Sequence Diagram\n   418→\n   419→```mermaid\n   420→sequenceDiagram\n   421→    participant C as Customer Browser\n   422→    participant API as Storefront API\n   423→    participant Stripe as Stripe API\n   424→    participant WH as Webhook Handler\n   425→\n   426→    C->>API: POST /checkouts (cart_id, email)\n   427→    API-->>C: 201 Checkout (status: started)\n   428→\n   429→    C->>API: PUT /checkouts/{id}/address\n   430→    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n   431→\n   432→    C->>API: PUT /checkouts/{id}/shipping-method\n   433→    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n   434→\n   435→    opt Apply discount\n   436→        C->>API: POST /checkouts/{id}/apply-discount\n   437→        API-->>C: 200 Checkout (totals recalculated)\n   438→    end\n   439→\n   440→    alt Stripe Checkout Session\n   441→        C->>API: POST /payments/stripe/checkout-session\n   442→        API->>Stripe: Create Checkout Session\n   443→        Stripe-->>API: session_url\n   444→        API-->>C: 201 (session_url)\n   445→        C->>Stripe: Redirect to hosted payment page\n   446→        Stripe-->>C: Redirect to success_url\n   447→        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   448→        WH-->>Stripe: 200 OK\n   449→    else Stripe Elements (embedded)\n   450→        C->>API: POST /payments/stripe/payment-intent\n   451→        API->>Stripe: Create PaymentIntent\n   452→        Stripe-->>API: client_secret\n   453→        API-->>C: 201 (client_secret)\n   454→        C->>Stripe: Confirm payment (client-side)\n   455→        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   456→        WH-->>Stripe: 200 OK\n   457→    else PayPal\n   458→        C->>API: POST /payments/paypal/create-order\n   459→        API-->>C: 201 (approval_url)\n   460→        C->>C: Redirect to PayPal approval\n   461→        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n   462→        WH-->>C: 200 OK\n   463→    end\n   464→\n   465→    C->>API: GET /checkouts/{id} (poll or redirect)\n   466→    API-->>C: 200 Checkout (status: completed)\n   467→```\n   468→\n   469→#### POST /api/storefront/v1/checkouts\n   470→\n   471→Create a checkout from a cart.\n   472→\n   473→- **Authentication:** None or session-based\n   474→- **Rate limit:** `checkout` (10/min per session)\n   475→\n   476→**Request body:**\n   477→\n   478→```json\n   479→{\n   480→  \"cart_id\": 42,\n   481→  \"email\": \"customer@example.com\"\n   482→}\n   483→```\n   484→\n   485→| Field | Type | Required | Validation |\n   486→|-------|------|----------|------------|\n   487→| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n   488→| `email` | string | Yes | Valid email address. |\n   489→\n   490→**Success response:** `201 Created`\n   491→\n   492→```json\n   493→{\n   494→  \"id\": 7,\n   495→  \"store_id\": 1,\n   496→  \"cart_id\": 42,\n   497→  \"customer_id\": null,\n   498→  \"status\": \"started\",\n   499→  \"email\": \"customer@example.com\","}]},"uuid":"a56305c3-7ba6-4b58-b078-00e18fba0f5f","timestamp":"2026-02-11T15:07:59.423Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe API\n    participant WH as Webhook Handler\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    alt Stripe Checkout Session\n        C->>API: POST /payments/stripe/checkout-session\n        API->>Stripe: Create Checkout Session\n        Stripe-->>API: session_url\n        API-->>C: 201 (session_url)\n        C->>Stripe: Redirect to hosted payment page\n        Stripe-->>C: Redirect to success_url\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else Stripe Elements (embedded)\n        C->>API: POST /payments/stripe/payment-intent\n        API->>Stripe: Create PaymentIntent\n        Stripe-->>API: client_secret\n        API-->>C: 201 (client_secret)\n        C->>Stripe: Confirm payment (client-side)\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else PayPal\n        C->>API: POST /payments/paypal/create-order\n        API-->>C: 201 (approval_url)\n        C->>C: Redirect to PayPal approval\n        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n        WH-->>C: 200 OK\n    end\n\n    C->>API: GET /checkouts/{id} (poll or redirect)\n    API-->>C: 200 Checkout (status: completed)\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",","numLines":400,"startLine":100,"totalLines":3383}},"sourceToolAssistantUUID":"fff68866-3b55-4d19-b47e-c9fe4a34a95b"}
{"parentUuid":"a56305c3-7ba6-4b58-b078-00e18fba0f5f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014AL67BJ47rij3sy9c6L3Jo","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Creu54TZqaJwFdd5mpy11M","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","offset":100,"limit":400}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":25,"cache_read_input_tokens":129582,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VPhvKkqKLnVooLf1LB","type":"assistant","uuid":"47243db5-76f5-42b2-b5f3-457a65e25e04","timestamp":"2026-02-11T15:07:59.454Z"}
{"parentUuid":"47243db5-76f5-42b2-b5f3-457a65e25e04","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01Creu54TZqaJwFdd5mpy11M","toolUseID":"toolu_01Creu54TZqaJwFdd5mpy11M","timestamp":"2026-02-11T15:07:59.455Z","uuid":"54ef0fa7-c5e9-41bd-923d-910c4d6e239a"}
{"parentUuid":"47243db5-76f5-42b2-b5f3-457a65e25e04","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Creu54TZqaJwFdd5mpy11M","type":"tool_result","content":"   100→\n   101→Each nav item displays an icon (via Flux icon component) and a text label. The active item receives a highlighted background and bold text. All navigation links use Livewire SPA-style page transitions. Groups are visually separated with Flux separator components.\n   102→\n   103→#### Flux Component Mapping\n   104→\n   105→| UI Element | Flux Component |\n   106→|-----------|----------------|\n   107→| Platform logo and name | `brand` |\n   108→| Navigation icons | `icon` |\n   109→| Group dividers | `separator` |\n   110→\n   111→#### Responsive Behavior\n   112→\n   113→- **Desktop (lg and above):** Sidebar is always visible, fixed left, 256px wide. Main content has a left margin of 256px.\n   114→- **Mobile (below lg):** Sidebar is hidden off-screen. A hamburger button (icon: bars-3) appears in the top bar. When user clicks it, the sidebar slides in as an overlay with a semi-transparent backdrop. Clicking the backdrop or a nav item closes it.\n   115→\n   116→### 1.3 Top Bar\n   117→\n   118→**Livewire component:** `App\\Livewire\\Admin\\Layout\\TopBar`\n   119→\n   120→**Blade view:** `resources/views/livewire/admin/layout/top-bar.blade.php`\n   121→\n   122→#### Component Requirements\n   123→\n   124→| Property | Type | Purpose |\n   125→|----------|------|---------|\n   126→| `currentStoreName` | `string` | Name of the active store |\n   127→| `stores` | `Collection` | List of stores the user has access to |\n   128→| `unreadNotificationCount` | `int` | Count of unread notifications |\n   129→\n   130→| Action | Behavior |\n   131→|--------|----------|\n   132→| `switchStore(string $storeId)` | Switches active store in session, redirects to dashboard |\n   133→\n   134→#### Wireframe\n   135→\n   136→```\n   137→+--------------------------------------------------------------------+\n   138→| [hamburger*]  [Store Name v]                  [bell(3)]  [avatar v] |\n   139→+--------------------------------------------------------------------+\n   140→  * hamburger visible on mobile only\n   141→\n   142→  Store Name dropdown:\n   143→    - Store A\n   144→    - Store B\n   145→    - Store C\n   146→\n   147→  Avatar dropdown:\n   148→    - Settings (links to /admin/settings)\n   149→    - [separator]\n   150→    - Log out (POST /logout with CSRF)\n   151→```\n   152→\n   153→#### Interaction Flows\n   154→\n   155→- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.\n   156→- Notification bell shows an unread count badge (positioned absolutely over the icon) when `unreadNotificationCount > 0`.\n   157→- Profile dropdown is triggered by a Flux profile component showing the user's avatar and name.\n   158→\n   159→#### Flux Component Mapping\n   160→\n   161→| UI Element | Flux Component |\n   162→|-----------|----------------|\n   163→| Store selector | `dropdown` |\n   164→| Hamburger (mobile) | `button` (icon: bars-3) |\n   165→| Notification bell | `button` (icon: bell, variant: ghost) |\n   166→| Notification count | `badge` |\n   167→| User profile trigger | `profile` |\n   168→| Profile menu | `dropdown` |\n   169→| Menu divider | `separator` |\n   170→\n   171→### 1.4 Main Content Area\n   172→\n   173→The main content area sits to the right of the sidebar and below the top bar. Every page renders within this area.\n   174→\n   175→**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always \"Home\" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > \"Blue T-Shirt\".\n   176→\n   177→**Layout wrapper:** The content area has consistent padding (larger on desktop, smaller on mobile) and a max-width container centered horizontally.\n   178→\n   179→### 1.5 Toast Notifications\n   180→\n   181→A global toast notification system lives in the layout shell. It uses Alpine.js to listen for Livewire dispatched events.\n   182→\n   183→| Property | Value |\n   184→|----------|-------|\n   185→| Event name | `toast` |\n   186→| Payload | `{ type: 'success' / 'error' / 'info', message: string }` |\n   187→| Position | Top-right corner |\n   188→| Auto-dismiss | 5 seconds |\n   189→| Stacking | Supports multiple toasts |\n   190→| Styling | Rounded card, shadow, colored left border (green=success, red=error, blue=info) |\n   191→\n   192→Components dispatch toasts after successful actions (e.g., \"Product saved successfully.\") or on errors (\"Something went wrong. Please try again.\").\n   193→\n   194→### 1.6 Dark Mode\n   195→\n   196→The admin supports dark mode via Tailwind's `dark:` variant. The mode follows the user's system preference by default. A toggle can be added in the profile dropdown later. All components and custom markup must include dark mode variants.\n   197→\n   198→Dark mode preference is persisted in `localStorage` under the key `theme` with values `light` or `dark`. On page load, the stored preference is applied before first paint to avoid flash of incorrect theme. If no preference is stored, the system preference (`prefers-color-scheme`) is used as default.\n   199→\n   200→---\n   201→\n   202→## 2. Dashboard\n   203→\n   204→**Route:** `GET /admin`\n   205→\n   206→**Livewire component:** `App\\Livewire\\Admin\\Dashboard`\n   207→\n   208→**Blade view:** `resources/views/livewire/admin/dashboard.blade.php`\n   209→\n   210→### Component Requirements\n   211→\n   212→| Property | Type | Purpose |\n   213→|----------|------|---------|\n   214→| `dateRange` | `string` | Active date range preset: `today`, `last_7_days`, `last_30_days`, `custom` |\n   215→| `customStartDate` | `?string` | Custom range start (Y-m-d) |\n   216→| `customEndDate` | `?string` | Custom range end (Y-m-d) |\n   217→| `totalSales` | `int` | Total sales amount in minor units |\n   218→| `ordersCount` | `int` | Number of orders in range |\n   219→| `averageOrderValue` | `int` | Average order value in minor units |\n   220→| `visitorsCount` | `int` | Unique visitors in range |\n   221→| `salesChange` | `float` | Percentage change vs previous period |\n   222→| `ordersChange` | `float` | Percentage change vs previous period |\n   223→| `aovChange` | `float` | Percentage change vs previous period |\n   224→| `visitorsChange` | `float` | Percentage change vs previous period |\n   225→| `ordersChartData` | `array` | Array of `{date, count}` for last 30 days |\n   226→| `topProducts` | `array` | Top 5 products: `{title, units_sold, revenue}` |\n   227→| `funnelData` | `array` | Funnel steps: `{visits, add_to_cart, checkout_started, checkout_completed}` |\n   228→\n   229→| Action | Behavior |\n   230→|--------|----------|\n   231→| `updatedDateRange()` | Re-fetches all KPI data for the selected range |\n   232→| `loadKpis()` | Queries aggregated order/analytics data |\n   233→| `loadChart()` | Queries daily order counts for chart |\n   234→| `loadTopProducts()` | Queries top 5 products by revenue |\n   235→| `loadFunnel()` | Queries conversion funnel data |\n   236→\n   237→| Computed Property | Purpose |\n   238→|-------------------|---------|\n   239→| `formattedTotalSales` | Formatted currency string |\n   240→| `formattedAov` | Formatted currency string |\n   241→\n   242→### Wireframe\n   243→\n   244→```\n   245→+------------------------------------------------------------------+\n   246→| Dashboard                           [Today v] (date range picker) |\n   247→+------------------------------------------------------------------+\n   248→|                                                                    |\n   249→|  +-------------+  +-------------+  +-------------+  +----------+  |\n   250→|  | Total Sales |  | Orders      |  | Avg Order   |  | Visitors |  |\n   251→|  | $12,345     |  | 156         |  | $79.13      |  | 2,340    |  |\n   252→|  | [+12.3%] ^  |  | [+8.1%] ^  |  | [-2.1%] v  |  | [+5%] ^  |  |\n   253→|  +-------------+  +-------------+  +-------------+  +----------+  |\n   254→|                                                                    |\n   255→|  +--------------------------------------------------------------+  |\n   256→|  | Orders over time                                              |  |\n   257→|  |  [line/bar chart - daily order counts for selected range]     |  |\n   258→|  +--------------------------------------------------------------+  |\n   259→|                                                                    |\n   260→|  +------------------------------+  +----------------------------+  |\n   261→|  | Top products                 |  | Conversion funnel          |  |\n   262→|  | Product Title | Sold | Rev   |  | Visits       [========] N |  |\n   263→|  | Blue Shirt    | 45   | $900  |  | Add to Cart  [======]   N |  |\n   264→|  | Red Hat       | 32   | $640  |  | Checkout     [====]     N |  |\n   265→|  | Sneakers      | 28   | $2800 |  | Completed    [==]       N |  |\n   266→|  | ...           |      |       |  |                            |  |\n   267→|  +------------------------------+  +----------------------------+  |\n   268→+--------------------------------------------------------------------+\n   269→```\n   270→\n   271→### Layout Description\n   272→\n   273→**Page heading:** \"Dashboard\" displayed as a Flux heading (size xl).\n   274→\n   275→**Date range filter:** Positioned top-right. A Flux dropdown triggered by a button showing the current range label and a calendar icon. Options: Today, Last 7 days, Last 30 days, Custom range (reveals two date input fields when selected).\n   276→\n   277→### KPI Tiles Row\n   278→\n   279→A responsive 4-column grid (1 column on mobile, 2 on sm, 4 on lg).\n   280→\n   281→Each tile is a card containing:\n   282→- A muted-color text label (e.g., \"Total Sales\")\n   283→- A large heading with the formatted number value\n   284→- A row with a Flux badge showing percentage change (green if positive, red if negative) and a directional arrow icon\n   285→\n   286→Each tile shows a loading state (reduced opacity) while data refreshes.\n   287→\n   288→### Orders Chart\n   289→\n   290→A card below the KPI tiles containing:\n   291→- A Flux heading (size lg): \"Orders over time\"\n   292→- A Chart.js canvas (line or bar chart) showing daily order counts\n   293→- Chart.js initialized via Alpine.js; data passed from Livewire\n   294→- A spinner overlay shown during chart data loading\n   295→\n   296→### Top Products Table\n   297→\n   298→A card containing:\n   299→- A Flux heading (size lg): \"Top products\"\n   300→- A plain table with columns: Product Title, Units Sold, Revenue\n   301→- Maximum 5 rows\n   302→- Empty state text: \"No sales data for this period.\"\n   303→\n   304→### Conversion Funnel\n   305→\n   306→A card containing:\n   307→- A Flux heading (size lg): \"Conversion funnel\"\n   308→- Horizontal bar visualization using Tailwind utility widths\n   309→- Each step: label on the left, proportional-width bar on the right, count displayed\n   310→- Steps: Visits, Add to Cart, Checkout Started, Checkout Completed\n   311→- Bars use decreasing widths and progressively darker colors\n   312→\n   313→### Flux Component Mapping\n   314→\n   315→| UI Element | Flux Component |\n   316→|-----------|----------------|\n   317→| Page title | `heading` (size xl) |\n   318→| Section titles | `heading` (size lg) |\n   319→| Date range selector | `dropdown`, `button`, `icon` |\n   320→| Date inputs (custom range) | `input` (type date) |\n   321→| KPI labels | `text` |\n   322→| KPI values | `heading` (size xl) |\n   323→| Trend badges | `badge` |\n   324→| Trend arrows | `icon` |\n   325→\n   326→### View States\n   327→\n   328→| State | Behavior |\n   329→|-------|----------|\n   330→| Loading | KPI tiles show reduced opacity; chart shows spinner overlay |\n   331→| Empty | \"No sales data for this period.\" in top products; funnel bars at zero width |\n   332→| Populated | All tiles, chart, table, and funnel render with data |\n   333→| Error | Toast notification with error message |\n   334→\n   335→---\n   336→\n   337→## 3. Products List\n   338→\n   339→**Route:** `GET /admin/products`\n   340→\n   341→**Livewire component:** `App\\Livewire\\Admin\\Products\\Index`\n   342→\n   343→**Blade view:** `resources/views/livewire/admin/products/index.blade.php`\n   344→\n   345→### Component Requirements\n   346→\n   347→| Property | Type | Purpose |\n   348→|----------|------|---------|\n   349→| `search` | `string` | Search query, live-debounced at 300ms |\n   350→| `statusFilter` | `string` | Status filter: `all`, `draft`, `active`, `archived` |\n   351→| `typeFilter` | `string` | Product type filter |\n   352→| `selectedIds` | `array<int>` | Array of selected product IDs for bulk actions |\n   353→| `selectAll` | `bool` | Whether all visible rows are selected |\n   354→| `sortField` | `string` | Column to sort by (default: `updated_at`) |\n   355→| `sortDirection` | `string` | `asc` or `desc` |\n   356→\n   357→| Action | Behavior |\n   358→|--------|----------|\n   359→| `updatedSearch()` | Resets pagination |\n   360→| `updatedStatusFilter()` | Resets pagination |\n   361→| `updatedTypeFilter()` | Resets pagination |\n   362→| `bulkArchive()` | Archives all selected products |\n   363→| `bulkDelete()` | Soft-deletes selected products (with confirmation) |\n   364→| `bulkSetActive()` | Sets selected products to active status |\n   365→| `sortBy(string $field)` | Toggles sort direction or changes sort field |\n   366→| `toggleSelectAll()` | Selects or deselects all visible product IDs |\n   367→| `confirmBulkDelete()` | Opens delete confirmation modal |\n   368→\n   369→| Computed Property | Purpose |\n   370→|-------------------|---------|\n   371→| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |\n   372→| `productTypes` | Distinct product types for the type filter dropdown |\n   373→\n   374→### Wireframe\n   375→\n   376→```\n   377→+------------------------------------------------------------------+\n   378→| Products                                    [+ Add product]       |\n   379→+------------------------------------------------------------------+\n   380→| [search icon] Search products...   [Status: All v] [Type: All v] |\n   381→+------------------------------------------------------------------+\n   382→| (shown when items selected)                                       |\n   383→| \"3 products selected\"  [Set Active]  [Archive]  [Delete]         |\n   384→+------------------------------------------------------------------+\n   385→| [x] | IMG | Title ^     | Status  | Inventory | Type | Vendor | Updated ^  |\n   386→|-----|-----|-------------|---------|-----------|------|--------|-----------|\n   387→| [ ] | img | Blue Shirt  | Active  | 45        | Tops | Nike   | 2h ago    |\n   388→| [ ] | img | Red Hat     | Draft   | 12        | Hats | Adidas | 1d ago    |\n   389→| [ ] | img | Sneakers    | Active  | 0         | Shoes| Nike   | 3d ago    |\n   390→| ...                                                              |\n   391→+------------------------------------------------------------------+\n   392→| < 1 2 3 ... 10 >  (pagination)                                  |\n   393→+------------------------------------------------------------------+\n   394→```\n   395→\n   396→### Table Column Definitions\n   397→\n   398→| Column | Sortable | Content |\n   399→|--------|----------|---------|\n   400→| Select | No | Checkbox for bulk selection |\n   401→| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |\n   402→| Title | Yes | Product title as a link to the edit page (uses Livewire navigate) |\n   403→| Status | No | Flux badge: draft=zinc/gray, active=green, archived=red |\n   404→| Inventory | Yes | Sum of `inventory_items.quantity_on_hand` across all variants |\n   405→| Type | No | Product type text or \"-\" |\n   406→| Vendor | No | Vendor name text or \"-\" |\n   407→| Updated | Yes (default) | Relative timestamp (e.g., \"2h ago\") |\n   408→\n   409→### Bulk Action Bar\n   410→\n   411→Conditionally shown when `count($selectedIds) > 0`. Contains:\n   412→- Text showing \"X products selected\"\n   413→- \"Set Active\" button (ghost variant)\n   414→- \"Archive\" button (ghost variant)\n   415→- \"Delete\" button (danger variant) - opens confirmation modal\n   416→\n   417→### Empty State\n   418→\n   419→When no products exist (not filtered, genuinely empty):\n   420→- Centered container with a large muted placeholder icon (cube)\n   421→- Heading: \"Add your first product\"\n   422→- Text: \"Start building your catalog by adding products.\"\n   423→- Primary button: \"Add product\" linking to the create page\n   424→\n   425→### Delete Confirmation Modal\n   426→\n   427→| Property | Value |\n   428→|----------|-------|\n   429→| Modal name | `confirm-bulk-delete` |\n   430→| Max width | md |\n   431→| Heading | \"Delete products?\" |\n   432→| Body text | \"This will archive X product(s). Products with orders cannot be permanently deleted.\" |\n   433→| Cancel button | Ghost variant, closes modal |\n   434→| Confirm button | Danger variant, calls `bulkDelete` |\n   435→\n   436→### Flux Component Mapping\n   437→\n   438→| UI Element | Flux Component |\n   439→|-----------|----------------|\n   440→| Page title | `heading` |\n   441→| Add product button | `button` (variant: primary) |\n   442→| Search field | `input` (icon: magnifying-glass) |\n   443→| Status filter | `select` |\n   444→| Type filter | `select` |\n   445→| Bulk action buttons | `button` (ghost/danger variants) |\n   446→| Selected count text | `text` |\n   447→| Row checkbox | `checkbox` |\n   448→| Status badges | `badge` |\n   449→| Cell text | `text` |\n   450→| Delete confirmation | `modal` |\n   451→| Pagination | Laravel default Tailwind pagination |\n   452→\n   453→### View States\n   454→\n   455→| State | Behavior |\n   456→|-------|----------|\n   457→| Loading | Table body shows reduced opacity |\n   458→| Empty (no products) | Empty state with illustration and CTA |\n   459→| Empty (filtered) | Table with \"No products match your filters\" message |\n   460→| Populated | Table with data rows and pagination |\n   461→\n   462→---\n   463→\n   464→## 4. Product Create/Edit\n   465→\n   466→**Route:** `GET /admin/products/create` and `GET /admin/products/{product}/edit`\n   467→\n   468→**Livewire component:** `App\\Livewire\\Admin\\Products\\Form`\n   469→\n   470→**Blade view:** `resources/views/livewire/admin/products/form.blade.php`\n   471→\n   472→### Component Requirements\n   473→\n   474→| Property | Type | Purpose |\n   475→|----------|------|---------|\n   476→| `product` | `?Product` | The product being edited, null for create |\n   477→| `title` | `string` | Product title |\n   478→| `descriptionHtml` | `string` | Product description (HTML) |\n   479→| `status` | `string` | `draft`, `active`, `archived` |\n   480→| `vendor` | `string` | Vendor name |\n   481→| `productType` | `string` | Product type string |\n   482→| `tags` | `string` | Comma-separated tags |\n   483→| `handle` | `string` | URL handle/slug |\n   484→| `publishedAt` | `?string` | Publish date (Y-m-d H:i) |\n   485→| `collectionIds` | `array<int>` | Selected collection IDs |\n   486→| `options` | `array` | Options array: `[{name: string, values: string[]}]` |\n   487→| `variants` | `array` | Variants array: `[{sku, price, compareAtPrice, quantity, requiresShipping, optionValues}]` |\n   488→| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |\n   489→| `newMedia` | `array` | Temporary uploaded files |\n   490→\n   491→| Action | Behavior |\n   492→|--------|----------|\n   493→| `mount(?Product $product)` | Loads product data into properties or sets defaults for create |\n   494→| `save()` | Validates and saves/creates the product, variants, options, and media |\n   495→| `addOption()` | Adds a new empty option `{name: '', values: []}` |\n   496→| `removeOption(int $index)` | Removes an option and regenerates variants |\n   497→| `addOptionValue(int $optionIndex)` | Adds a value to an option |\n   498→| `removeOptionValue(int $optionIndex, int $valueIndex)` | Removes a value |\n   499→| `generateVariants()` | Generates variant combinations from current options |"}]},"uuid":"02ce9bb6-a8d0-4435-b5eb-8017043c9ba8","timestamp":"2026-02-11T15:07:59.455Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","content":"\nEach nav item displays an icon (via Flux icon component) and a text label. The active item receives a highlighted background and bold text. All navigation links use Livewire SPA-style page transitions. Groups are visually separated with Flux separator components.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Platform logo and name | `brand` |\n| Navigation icons | `icon` |\n| Group dividers | `separator` |\n\n#### Responsive Behavior\n\n- **Desktop (lg and above):** Sidebar is always visible, fixed left, 256px wide. Main content has a left margin of 256px.\n- **Mobile (below lg):** Sidebar is hidden off-screen. A hamburger button (icon: bars-3) appears in the top bar. When user clicks it, the sidebar slides in as an overlay with a semi-transparent backdrop. Clicking the backdrop or a nav item closes it.\n\n### 1.3 Top Bar\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\TopBar`\n\n**Blade view:** `resources/views/livewire/admin/layout/top-bar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `currentStoreName` | `string` | Name of the active store |\n| `stores` | `Collection` | List of stores the user has access to |\n| `unreadNotificationCount` | `int` | Count of unread notifications |\n\n| Action | Behavior |\n|--------|----------|\n| `switchStore(string $storeId)` | Switches active store in session, redirects to dashboard |\n\n#### Wireframe\n\n```\n+--------------------------------------------------------------------+\n| [hamburger*]  [Store Name v]                  [bell(3)]  [avatar v] |\n+--------------------------------------------------------------------+\n  * hamburger visible on mobile only\n\n  Store Name dropdown:\n    - Store A\n    - Store B\n    - Store C\n\n  Avatar dropdown:\n    - Settings (links to /admin/settings)\n    - [separator]\n    - Log out (POST /logout with CSRF)\n```\n\n#### Interaction Flows\n\n- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.\n- Notification bell shows an unread count badge (positioned absolutely over the icon) when `unreadNotificationCount > 0`.\n- Profile dropdown is triggered by a Flux profile component showing the user's avatar and name.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Store selector | `dropdown` |\n| Hamburger (mobile) | `button` (icon: bars-3) |\n| Notification bell | `button` (icon: bell, variant: ghost) |\n| Notification count | `badge` |\n| User profile trigger | `profile` |\n| Profile menu | `dropdown` |\n| Menu divider | `separator` |\n\n### 1.4 Main Content Area\n\nThe main content area sits to the right of the sidebar and below the top bar. Every page renders within this area.\n\n**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always \"Home\" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > \"Blue T-Shirt\".\n\n**Layout wrapper:** The content area has consistent padding (larger on desktop, smaller on mobile) and a max-width container centered horizontally.\n\n### 1.5 Toast Notifications\n\nA global toast notification system lives in the layout shell. It uses Alpine.js to listen for Livewire dispatched events.\n\n| Property | Value |\n|----------|-------|\n| Event name | `toast` |\n| Payload | `{ type: 'success' / 'error' / 'info', message: string }` |\n| Position | Top-right corner |\n| Auto-dismiss | 5 seconds |\n| Stacking | Supports multiple toasts |\n| Styling | Rounded card, shadow, colored left border (green=success, red=error, blue=info) |\n\nComponents dispatch toasts after successful actions (e.g., \"Product saved successfully.\") or on errors (\"Something went wrong. Please try again.\").\n\n### 1.6 Dark Mode\n\nThe admin supports dark mode via Tailwind's `dark:` variant. The mode follows the user's system preference by default. A toggle can be added in the profile dropdown later. All components and custom markup must include dark mode variants.\n\nDark mode preference is persisted in `localStorage` under the key `theme` with values `light` or `dark`. On page load, the stored preference is applied before first paint to avoid flash of incorrect theme. If no preference is stored, the system preference (`prefers-color-scheme`) is used as default.\n\n---\n\n## 2. Dashboard\n\n**Route:** `GET /admin`\n\n**Livewire component:** `App\\Livewire\\Admin\\Dashboard`\n\n**Blade view:** `resources/views/livewire/admin/dashboard.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Active date range preset: `today`, `last_7_days`, `last_30_days`, `custom` |\n| `customStartDate` | `?string` | Custom range start (Y-m-d) |\n| `customEndDate` | `?string` | Custom range end (Y-m-d) |\n| `totalSales` | `int` | Total sales amount in minor units |\n| `ordersCount` | `int` | Number of orders in range |\n| `averageOrderValue` | `int` | Average order value in minor units |\n| `visitorsCount` | `int` | Unique visitors in range |\n| `salesChange` | `float` | Percentage change vs previous period |\n| `ordersChange` | `float` | Percentage change vs previous period |\n| `aovChange` | `float` | Percentage change vs previous period |\n| `visitorsChange` | `float` | Percentage change vs previous period |\n| `ordersChartData` | `array` | Array of `{date, count}` for last 30 days |\n| `topProducts` | `array` | Top 5 products: `{title, units_sold, revenue}` |\n| `funnelData` | `array` | Funnel steps: `{visits, add_to_cart, checkout_started, checkout_completed}` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Re-fetches all KPI data for the selected range |\n| `loadKpis()` | Queries aggregated order/analytics data |\n| `loadChart()` | Queries daily order counts for chart |\n| `loadTopProducts()` | Queries top 5 products by revenue |\n| `loadFunnel()` | Queries conversion funnel data |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `formattedTotalSales` | Formatted currency string |\n| `formattedAov` | Formatted currency string |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Dashboard                           [Today v] (date range picker) |\n+------------------------------------------------------------------+\n|                                                                    |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|  | Total Sales |  | Orders      |  | Avg Order   |  | Visitors |  |\n|  | $12,345     |  | 156         |  | $79.13      |  | 2,340    |  |\n|  | [+12.3%] ^  |  | [+8.1%] ^  |  | [-2.1%] v  |  | [+5%] ^  |  |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|                                                                    |\n|  +--------------------------------------------------------------+  |\n|  | Orders over time                                              |  |\n|  |  [line/bar chart - daily order counts for selected range]     |  |\n|  +--------------------------------------------------------------+  |\n|                                                                    |\n|  +------------------------------+  +----------------------------+  |\n|  | Top products                 |  | Conversion funnel          |  |\n|  | Product Title | Sold | Rev   |  | Visits       [========] N |  |\n|  | Blue Shirt    | 45   | $900  |  | Add to Cart  [======]   N |  |\n|  | Red Hat       | 32   | $640  |  | Checkout     [====]     N |  |\n|  | Sneakers      | 28   | $2800 |  | Completed    [==]       N |  |\n|  | ...           |      |       |  |                            |  |\n|  +------------------------------+  +----------------------------+  |\n+--------------------------------------------------------------------+\n```\n\n### Layout Description\n\n**Page heading:** \"Dashboard\" displayed as a Flux heading (size xl).\n\n**Date range filter:** Positioned top-right. A Flux dropdown triggered by a button showing the current range label and a calendar icon. Options: Today, Last 7 days, Last 30 days, Custom range (reveals two date input fields when selected).\n\n### KPI Tiles Row\n\nA responsive 4-column grid (1 column on mobile, 2 on sm, 4 on lg).\n\nEach tile is a card containing:\n- A muted-color text label (e.g., \"Total Sales\")\n- A large heading with the formatted number value\n- A row with a Flux badge showing percentage change (green if positive, red if negative) and a directional arrow icon\n\nEach tile shows a loading state (reduced opacity) while data refreshes.\n\n### Orders Chart\n\nA card below the KPI tiles containing:\n- A Flux heading (size lg): \"Orders over time\"\n- A Chart.js canvas (line or bar chart) showing daily order counts\n- Chart.js initialized via Alpine.js; data passed from Livewire\n- A spinner overlay shown during chart data loading\n\n### Top Products Table\n\nA card containing:\n- A Flux heading (size lg): \"Top products\"\n- A plain table with columns: Product Title, Units Sold, Revenue\n- Maximum 5 rows\n- Empty state text: \"No sales data for this period.\"\n\n### Conversion Funnel\n\nA card containing:\n- A Flux heading (size lg): \"Conversion funnel\"\n- Horizontal bar visualization using Tailwind utility widths\n- Each step: label on the left, proportional-width bar on the right, count displayed\n- Steps: Visits, Add to Cart, Checkout Started, Checkout Completed\n- Bars use decreasing widths and progressively darker colors\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` (size xl) |\n| Section titles | `heading` (size lg) |\n| Date range selector | `dropdown`, `button`, `icon` |\n| Date inputs (custom range) | `input` (type date) |\n| KPI labels | `text` |\n| KPI values | `heading` (size xl) |\n| Trend badges | `badge` |\n| Trend arrows | `icon` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | KPI tiles show reduced opacity; chart shows spinner overlay |\n| Empty | \"No sales data for this period.\" in top products; funnel bars at zero width |\n| Populated | All tiles, chart, table, and funnel render with data |\n| Error | Toast notification with error message |\n\n---\n\n## 3. Products List\n\n**Route:** `GET /admin/products`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Index`\n\n**Blade view:** `resources/views/livewire/admin/products/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query, live-debounced at 300ms |\n| `statusFilter` | `string` | Status filter: `all`, `draft`, `active`, `archived` |\n| `typeFilter` | `string` | Product type filter |\n| `selectedIds` | `array<int>` | Array of selected product IDs for bulk actions |\n| `selectAll` | `bool` | Whether all visible rows are selected |\n| `sortField` | `string` | Column to sort by (default: `updated_at`) |\n| `sortDirection` | `string` | `asc` or `desc` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `updatedTypeFilter()` | Resets pagination |\n| `bulkArchive()` | Archives all selected products |\n| `bulkDelete()` | Soft-deletes selected products (with confirmation) |\n| `bulkSetActive()` | Sets selected products to active status |\n| `sortBy(string $field)` | Toggles sort direction or changes sort field |\n| `toggleSelectAll()` | Selects or deselects all visible product IDs |\n| `confirmBulkDelete()` | Opens delete confirmation modal |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |\n| `productTypes` | Distinct product types for the type filter dropdown |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Products                                    [+ Add product]       |\n+------------------------------------------------------------------+\n| [search icon] Search products...   [Status: All v] [Type: All v] |\n+------------------------------------------------------------------+\n| (shown when items selected)                                       |\n| \"3 products selected\"  [Set Active]  [Archive]  [Delete]         |\n+------------------------------------------------------------------+\n| [x] | IMG | Title ^     | Status  | Inventory | Type | Vendor | Updated ^  |\n|-----|-----|-------------|---------|-----------|------|--------|-----------|\n| [ ] | img | Blue Shirt  | Active  | 45        | Tops | Nike   | 2h ago    |\n| [ ] | img | Red Hat     | Draft   | 12        | Hats | Adidas | 1d ago    |\n| [ ] | img | Sneakers    | Active  | 0         | Shoes| Nike   | 3d ago    |\n| ...                                                              |\n+------------------------------------------------------------------+\n| < 1 2 3 ... 10 >  (pagination)                                  |\n+------------------------------------------------------------------+\n```\n\n### Table Column Definitions\n\n| Column | Sortable | Content |\n|--------|----------|---------|\n| Select | No | Checkbox for bulk selection |\n| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |\n| Title | Yes | Product title as a link to the edit page (uses Livewire navigate) |\n| Status | No | Flux badge: draft=zinc/gray, active=green, archived=red |\n| Inventory | Yes | Sum of `inventory_items.quantity_on_hand` across all variants |\n| Type | No | Product type text or \"-\" |\n| Vendor | No | Vendor name text or \"-\" |\n| Updated | Yes (default) | Relative timestamp (e.g., \"2h ago\") |\n\n### Bulk Action Bar\n\nConditionally shown when `count($selectedIds) > 0`. Contains:\n- Text showing \"X products selected\"\n- \"Set Active\" button (ghost variant)\n- \"Archive\" button (ghost variant)\n- \"Delete\" button (danger variant) - opens confirmation modal\n\n### Empty State\n\nWhen no products exist (not filtered, genuinely empty):\n- Centered container with a large muted placeholder icon (cube)\n- Heading: \"Add your first product\"\n- Text: \"Start building your catalog by adding products.\"\n- Primary button: \"Add product\" linking to the create page\n\n### Delete Confirmation Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-bulk-delete` |\n| Max width | md |\n| Heading | \"Delete products?\" |\n| Body text | \"This will archive X product(s). Products with orders cannot be permanently deleted.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `bulkDelete` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Add product button | `button` (variant: primary) |\n| Search field | `input` (icon: magnifying-glass) |\n| Status filter | `select` |\n| Type filter | `select` |\n| Bulk action buttons | `button` (ghost/danger variants) |\n| Selected count text | `text` |\n| Row checkbox | `checkbox` |\n| Status badges | `badge` |\n| Cell text | `text` |\n| Delete confirmation | `modal` |\n| Pagination | Laravel default Tailwind pagination |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | Table body shows reduced opacity |\n| Empty (no products) | Empty state with illustration and CTA |\n| Empty (filtered) | Table with \"No products match your filters\" message |\n| Populated | Table with data rows and pagination |\n\n---\n\n## 4. Product Create/Edit\n\n**Route:** `GET /admin/products/create` and `GET /admin/products/{product}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Form`\n\n**Blade view:** `resources/views/livewire/admin/products/form.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `product` | `?Product` | The product being edited, null for create |\n| `title` | `string` | Product title |\n| `descriptionHtml` | `string` | Product description (HTML) |\n| `status` | `string` | `draft`, `active`, `archived` |\n| `vendor` | `string` | Vendor name |\n| `productType` | `string` | Product type string |\n| `tags` | `string` | Comma-separated tags |\n| `handle` | `string` | URL handle/slug |\n| `publishedAt` | `?string` | Publish date (Y-m-d H:i) |\n| `collectionIds` | `array<int>` | Selected collection IDs |\n| `options` | `array` | Options array: `[{name: string, values: string[]}]` |\n| `variants` | `array` | Variants array: `[{sku, price, compareAtPrice, quantity, requiresShipping, optionValues}]` |\n| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |\n| `newMedia` | `array` | Temporary uploaded files |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(?Product $product)` | Loads product data into properties or sets defaults for create |\n| `save()` | Validates and saves/creates the product, variants, options, and media |\n| `addOption()` | Adds a new empty option `{name: '', values: []}` |\n| `removeOption(int $index)` | Removes an option and regenerates variants |\n| `addOptionValue(int $optionIndex)` | Adds a value to an option |\n| `removeOptionValue(int $optionIndex, int $valueIndex)` | Removes a value |\n| `generateVariants()` | Generates variant combinations from current options |","numLines":400,"startLine":100,"totalLines":2403}},"sourceToolAssistantUUID":"47243db5-76f5-42b2-b5f3-457a65e25e04"}
{"parentUuid":"02ce9bb6-a8d0-4435-b5eb-8017043c9ba8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014AL67BJ47rij3sy9c6L3Jo","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JXwoyEoVxTPUdfx9kaNWsa","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","offset":100,"limit":400}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":25,"cache_read_input_tokens":129582,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VPhvKkqKLnVooLf1LB","type":"assistant","uuid":"35e13348-c1c1-4a42-904f-cac26bd5cb46","timestamp":"2026-02-11T15:07:59.460Z"}
{"type":"queue-operation","operation":"remove","timestamp":"2026-02-11T15:07:59.488Z","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b"}
{"parentUuid":"35e13348-c1c1-4a42-904f-cac26bd5cb46","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01JXwoyEoVxTPUdfx9kaNWsa","toolUseID":"toolu_01JXwoyEoVxTPUdfx9kaNWsa","timestamp":"2026-02-11T15:07:59.462Z","uuid":"3eb53d89-f122-427c-9cfd-2402b34737b0"}
{"parentUuid":"35e13348-c1c1-4a42-904f-cac26bd5cb46","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JXwoyEoVxTPUdfx9kaNWsa","type":"tool_result","content":"   100→| Character encoding | UTF-8 |\n   101→| Viewport | Width = device width, initial scale 1 |\n   102→| Meta description | Page-specific, yielded from child templates |\n   103→| Title | Page-specific title appended with the app name |\n   104→| Stylesheets | Compiled CSS via Vite |\n   105→| Scripts | Compiled JS via Vite |\n   106→| Livewire styles | Injected by Livewire |\n   107→| Language attribute | Set on the HTML element from application locale |\n   108→| Scroll behavior | Smooth scrolling enabled globally |\n   109→\n   110→### 2.2 Skip Link\n   111→\n   112→A visually hidden \"Skip to main content\" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.\n   113→\n   114→**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.\n   115→\n   116→### 2.3 Announcement Bar\n   117→\n   118→Optional, controlled by theme settings. Renders a full-width bar above the header.\n   119→\n   120→| Attribute | Description |\n   121→|---|---|\n   122→| Position | Full-width, above the header |\n   123→| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |\n   124→| Text | Centered, small size, configurable content |\n   125→| Link | Optional, underlined, opens in same tab |\n   126→| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |\n   127→| Height | Auto height with vertical padding |\n   128→\n   129→### 2.4 Header\n   130→\n   131→The header serves as the primary navigation banner.\n   132→\n   133→**Desktop Layout (large breakpoint and above):**\n   134→\n   135→```\n   136→+--------------------------------------------------------------+\n   137→| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |\n   138→+--------------------------------------------------------------+\n   139→```\n   140→\n   141→| Element | Position | Description |\n   142→|---|---|---|\n   143→| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |\n   144→| Main Navigation | Center | Horizontal nav with links sourced from the \"main-menu\" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |\n   145→| Search Icon | Right group | Button that opens the search modal |\n   146→| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |\n   147→| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |\n   148→\n   149→**Mobile Layout (below large breakpoint):**\n   150→\n   151→```\n   152→+--------------------------------------------------------------+\n   153→| [Hamburger]           [Logo]                            [Cart]|\n   154→+--------------------------------------------------------------+\n   155→```\n   156→\n   157→| Element | Position | Description |\n   158→|---|---|---|\n   159→| Hamburger menu | Left | Toggle button for mobile navigation drawer |\n   160→| Logo | Center | Same as desktop but horizontally centered |\n   161→| Cart icon | Right | Same as desktop |\n   162→\n   163→**Sticky Header (optional):**\n   164→\n   165→When enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.\n   166→\n   167→**Mobile Navigation Drawer:**\n   168→\n   169→| Attribute | Description |\n   170→|---|---|\n   171→| Animation | Slides in from the left side |\n   172→| Height | Full viewport height |\n   173→| Backdrop | Semi-transparent dark overlay behind the drawer |\n   174→| Close button | X icon at the top-right of the drawer |\n   175→| Navigation items | Vertical stack with comfortable tap targets |\n   176→| Submenus | Collapsible accordion pattern with chevron icons |\n   177→| Account link | Positioned at the bottom of the navigation list |\n   178→| Focus behavior | Focus is trapped within the drawer while open |\n   179→| ARIA | Labeled as \"Mobile navigation\" |\n   180→\n   181→### 2.5 Main Content Area\n   182→\n   183→The main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.\n   184→\n   185→### 2.6 Footer\n   186→\n   187→The footer is a content-info landmark.\n   188→\n   189→**Layout:**\n   190→\n   191→```\n   192→+--------------------------------------------------------------+\n   193→| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n   194→|                                                               |\n   195→| [Social Icons]                                                |\n   196→| [Copyright] [Payment Icons]                                   |\n   197→+--------------------------------------------------------------+\n   198→```\n   199→\n   200→| Element | Description |\n   201→|---|---|\n   202→| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n   203→| Store info column | Store name, address (if configured), contact email |\n   204→| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n   205→| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n   206→| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n   207→| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n   208→| Background | Light gray (dark: very dark gray), top border separator |\n   209→\n   210→### 2.7 Cart Drawer (Livewire)\n   211→\n   212→The cart drawer is always present in the layout as a global Livewire component. It listens for the \"cart-updated\" browser event to refresh and open. See Section 6 for full specification.\n   213→\n   214→### 2.8 Dark Mode\n   215→\n   216→Dark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:\n   217→\n   218→| Mode | Implementation |\n   219→|---|---|\n   220→| System preference (default) | Follows OS-level dark mode preference automatically |\n   221→| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |\n   222→| Forced light/dark | Applied at server render time on the HTML element |\n   223→\n   224→**Color Token Patterns:**\n   225→\n   226→| Token | Light Mode | Dark Mode |\n   227→|---|---|---|\n   228→| Page background | White | Near-black |\n   229→| Card/section background | Light gray | Very dark gray |\n   230→| Heading text | Near-black | White |\n   231→| Body text | Medium gray | Light gray |\n   232→| Borders | Light gray | Medium-dark gray |\n   233→| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |\n   234→\n   235→---\n   236→\n   237→## 3. Home Page (GET /)\n   238→\n   239→The home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:\n   240→\n   241→1. Hero Banner\n   242→2. Featured Collections\n   243→3. Featured Products\n   244→4. Newsletter Signup\n   245→5. Rich Text\n   246→\n   247→### 3.1 Hero Banner Section\n   248→\n   249→**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.\n   250→\n   251→**Desktop (large breakpoint and above):**\n   252→\n   253→```\n   254→+--------------------------------------------------------------+\n   255→|                                                               |\n   256→|  [Background Image / Gradient]                                |\n   257→|                                                               |\n   258→|       [Heading - very large, bold]                            |\n   259→|       [Subheading - medium, regular weight]                   |\n   260→|       [CTA Button - primary, large]                           |\n   261→|                                                               |\n   262→+--------------------------------------------------------------+\n   263→```\n   264→\n   265→| Attribute | Description |\n   266→|---|---|\n   267→| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |\n   268→| Content | Centered vertically and horizontally, white text |\n   269→| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |\n   270→| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |\n   271→| CTA | Styled as primary button, large. Links to a configured collection or product |\n   272→| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |\n   273→| Padding | Generous vertical and horizontal padding |\n   274→\n   275→**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.\n   276→\n   277→**Accessibility:**\n   278→\n   279→- If background is an image, it is decorative (the content provides meaning)\n   280→- CTA button has descriptive text (e.g., \"Shop the summer collection\")\n   281→\n   282→### 3.2 Featured Collections Section\n   283→\n   284→**Layout:** Section with heading and grid of collection cards.\n   285→\n   286→| Attribute | Description |\n   287→|---|---|\n   288→| Section heading | H2, large, bold, centered, with generous bottom margin |\n   289→| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |\n   290→| Count | 2 to 4 collections, configurable in theme settings |\n   291→\n   292→**Collection Card:**\n   293→\n   294→```\n   295→+---------------------------+\n   296→|  [Collection Image]       |\n   297→|  3:4 aspect ratio         |\n   298→|  cover-fit, rounded       |\n   299→|                           |\n   300→|  [Title overlay at bottom]|\n   301→|  [Shop now ->]            |\n   302→+---------------------------+\n   303→```\n   304→\n   305→| Element | Description |\n   306→|---|---|\n   307→| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |\n   308→| Title | White text overlaid at bottom of image, semi-bold, medium-large size |\n   309→| \"Shop now\" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |\n   310→| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |\n   311→\n   312→**Accessibility:**\n   313→\n   314→- Each card link has the collection title as accessible name\n   315→- Images have alt text matching the collection title\n   316→\n   317→### 3.3 Featured Products Section\n   318→\n   319→**Livewire Component:** FeaturedProducts\n   320→\n   321→This section uses Livewire for lazy loading to improve initial page load.\n   322→\n   323→| Attribute | Description |\n   324→|---|---|\n   325→| Section heading | H2, same styling as featured collections heading |\n   326→| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |\n   327→| Products | 4 to 8, configurable. Sourced from a configured collection or \"best sellers\" logic |\n   328→| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |\n   329→\n   330→Each product uses the product-card component (see Section 16).\n   331→\n   332→### 3.4 Newsletter Signup Section\n   333→\n   334→**Livewire Component:** NewsletterSignup\n   335→\n   336→**Layout:**\n   337→\n   338→```\n   339→+--------------------------------------------------------------+\n   340→|  [Heading: \"Stay in the loop\"]                                |\n   341→|  [Subtext: \"Subscribe for exclusive offers and updates.\"]     |\n   342→|  [Email input] [Subscribe button]                             |\n   343→+--------------------------------------------------------------+\n   344→```\n   345→\n   346→| Attribute | Description |\n   347→|---|---|\n   348→| Background | Light gray (dark mode: darker gray), generous vertical padding |\n   349→| Content | Centered, constrained to a comfortable width |\n   350→| Heading | H2, medium-large, bold |\n   351→| Subtext | Muted color, small top margin |\n   352→| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |\n   353→| Input | Email type, placeholder \"Enter your email\", required |\n   354→| Button | Primary style, \"Subscribe\" text |\n   355→| Success state | Replaces form with \"Thanks for subscribing!\" message |\n   356→| Error state | Inline error below input in red, small text |\n   357→| Loading state | Button text changes to \"Subscribing...\" during submission |\n   358→\n   359→**Accessibility:**\n   360→\n   361→- Label associated with email input (visually hidden if using placeholder)\n   362→- Success/error messages in a polite live region for screen readers\n   363→\n   364→### 3.5 Rich Text Section\n   365→\n   366→Simple section rendering customizable content from theme settings.\n   367→\n   368→| Attribute | Description |\n   369→|---|---|\n   370→| Container | Constrained to comfortable reading width, centered, vertical padding |\n   371→| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |\n   372→| Dark mode | Inverted prose styling |\n   373→\n   374→---\n   375→\n   376→## 4. Collection Page (GET /collections/{handle})\n   377→\n   378→**Livewire Component:** CollectionPage\n   379→\n   380→### 4.1 Collection Header\n   381→\n   382→| Element | Description |\n   383→|---|---|\n   384→| Breadcrumbs | Home > Collections > {Collection Title} |\n   385→| Title | H1, large, bold |\n   386→| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |\n   387→| Spacing | Generous vertical padding |\n   388→\n   389→### 4.2 Toolbar\n   390→\n   391→Horizontal bar between the header and the product grid.\n   392→\n   393→**Desktop:**\n   394→\n   395→```\n   396→+--------------------------------------------------------------+\n   397→| [Filter toggle button]          [X products] [Sort dropdown]  |\n   398→+--------------------------------------------------------------+\n   399→```\n   400→\n   401→**Mobile:**\n   402→\n   403→```\n   404→+--------------------------------------------------------------+\n   405→| [Filter button]                           [Sort dropdown]     |\n   406→+--------------------------------------------------------------+\n   407→```\n   408→\n   409→| Element | Description |\n   410→|---|---|\n   411→| Product count | Small muted text, e.g., \"24 products\" |\n   412→| Sort dropdown | Select element with live Livewire binding |\n   413→\n   414→**Sort Options:**\n   415→\n   416→| Option | Description |\n   417→|---|---|\n   418→| Featured | Default sort order |\n   419→| Price: Low to High | Ascending by price |\n   420→| Price: High to Low | Descending by price |\n   421→| Newest | Descending by creation date |\n   422→| Best Selling | Descending by sales count |\n   423→\n   424→### 4.3 Filter Sidebar\n   425→\n   426→**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.\n   427→**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by \"Filter\" button with filter icon.\n   428→\n   429→**Filter Groups:**\n   430→\n   431→Each filter group is a collapsible section with a heading and toggle chevron.\n   432→\n   433→| Filter Group | Type | Binding | Description |\n   434→|---|---|---|---|\n   435→| Availability | Single checkbox | Live | \"In stock\" toggle |\n   436→| Price Range | Two number inputs | Live with 500ms debounce | \"Min\" and \"Max\" inputs with currency symbol prefix, displayed side by side |\n   437→| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |\n   438→| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |\n   439→\n   440→**Clear All:**\n   441→\n   442→- Link at the top of the filter sidebar: \"Clear all filters\"\n   443→- Only visible when at least one filter is active\n   444→- Small, blue, interactive text\n   445→\n   446→**Active Filter Pills:**\n   447→\n   448→- Displayed above the product grid (below toolbar) when filters are active\n   449→- Each pill: rounded tag with filter value text and a remove (X) button\n   450→- \"Clear all\" link at the end of the pills row\n   451→\n   452→**Mobile Filter Drawer:**\n   453→\n   454→| Attribute | Description |\n   455→|---|---|\n   456→| Position | Full-height overlay from left side |\n   457→| Header | \"Filters\" title + close button (X) |\n   458→| Content | Same filter groups as desktop, stacked vertically |\n   459→| Footer | Sticky \"Apply\" button at bottom that closes the drawer |\n   460→| Clear | \"Clear all\" link in header area |\n   461→| Focus | Trapped within drawer while open |\n   462→\n   463→### 4.4 Product Grid\n   464→\n   465→| Attribute | Description |\n   466→|---|---|\n   467→| Layout | Fills remaining width next to the sidebar |\n   468→| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |\n   469→| Loading state | Subtle opacity reduction on the grid while results update |\n   470→\n   471→### 4.5 Product Card (Component)\n   472→\n   473→The product card is a reusable component used on collection pages, search results, and featured products sections.\n   474→\n   475→```\n   476→+---------------------------+\n   477→|  [Product Image]          |\n   478→|  1:1 aspect ratio         |\n   479→|  cover-fit, rounded       |\n   480→|  (hover: show 2nd image)  |\n   481→|                           |\n   482→|  [Sale badge]  [Sold out] |\n   483→+---------------------------+\n   484→|  [Product Title]          |\n   485→|  [Price]  [Compare price] |\n   486→|  [Quick Add / Options]    |\n   487→+---------------------------+\n   488→```\n   489→\n   490→**Image Behavior:**\n   491→\n   492→| Attribute | Description |\n   493→|---|---|\n   494→| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |\n   495→| Primary image | Cover-fit, fills the container |\n   496→| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |\n   497→| Fallback | If no image, show a centered shopping bag placeholder icon |\n   498→| Loading | Lazy-loaded |\n   499→"}]},"uuid":"31683008-c336-41cb-ba73-0a7de1284202","timestamp":"2026-02-11T15:07:59.462Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"| Character encoding | UTF-8 |\n| Viewport | Width = device width, initial scale 1 |\n| Meta description | Page-specific, yielded from child templates |\n| Title | Page-specific title appended with the app name |\n| Stylesheets | Compiled CSS via Vite |\n| Scripts | Compiled JS via Vite |\n| Livewire styles | Injected by Livewire |\n| Language attribute | Set on the HTML element from application locale |\n| Scroll behavior | Smooth scrolling enabled globally |\n\n### 2.2 Skip Link\n\nA visually hidden \"Skip to main content\" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.\n\n**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.\n\n### 2.3 Announcement Bar\n\nOptional, controlled by theme settings. Renders a full-width bar above the header.\n\n| Attribute | Description |\n|---|---|\n| Position | Full-width, above the header |\n| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |\n| Text | Centered, small size, configurable content |\n| Link | Optional, underlined, opens in same tab |\n| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |\n| Height | Auto height with vertical padding |\n\n### 2.4 Header\n\nThe header serves as the primary navigation banner.\n\n**Desktop Layout (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |\n| Main Navigation | Center | Horizontal nav with links sourced from the \"main-menu\" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |\n| Search Icon | Right group | Button that opens the search modal |\n| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |\n| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |\n\n**Mobile Layout (below large breakpoint):**\n\n```\n+--------------------------------------------------------------+\n| [Hamburger]           [Logo]                            [Cart]|\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Hamburger menu | Left | Toggle button for mobile navigation drawer |\n| Logo | Center | Same as desktop but horizontally centered |\n| Cart icon | Right | Same as desktop |\n\n**Sticky Header (optional):**\n\nWhen enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.\n\n**Mobile Navigation Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Animation | Slides in from the left side |\n| Height | Full viewport height |\n| Backdrop | Semi-transparent dark overlay behind the drawer |\n| Close button | X icon at the top-right of the drawer |\n| Navigation items | Vertical stack with comfortable tap targets |\n| Submenus | Collapsible accordion pattern with chevron icons |\n| Account link | Positioned at the bottom of the navigation list |\n| Focus behavior | Focus is trapped within the drawer while open |\n| ARIA | Labeled as \"Mobile navigation\" |\n\n### 2.5 Main Content Area\n\nThe main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.\n\n### 2.6 Footer\n\nThe footer is a content-info landmark.\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n|                                                               |\n| [Social Icons]                                                |\n| [Copyright] [Payment Icons]                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n| Store info column | Store name, address (if configured), contact email |\n| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n| Background | Light gray (dark: very dark gray), top border separator |\n\n### 2.7 Cart Drawer (Livewire)\n\nThe cart drawer is always present in the layout as a global Livewire component. It listens for the \"cart-updated\" browser event to refresh and open. See Section 6 for full specification.\n\n### 2.8 Dark Mode\n\nDark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:\n\n| Mode | Implementation |\n|---|---|\n| System preference (default) | Follows OS-level dark mode preference automatically |\n| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |\n| Forced light/dark | Applied at server render time on the HTML element |\n\n**Color Token Patterns:**\n\n| Token | Light Mode | Dark Mode |\n|---|---|---|\n| Page background | White | Near-black |\n| Card/section background | Light gray | Very dark gray |\n| Heading text | Near-black | White |\n| Body text | Medium gray | Light gray |\n| Borders | Light gray | Medium-dark gray |\n| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |\n\n---\n\n## 3. Home Page (GET /)\n\nThe home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:\n\n1. Hero Banner\n2. Featured Collections\n3. Featured Products\n4. Newsletter Signup\n5. Rich Text\n\n### 3.1 Hero Banner Section\n\n**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.\n\n**Desktop (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n|                                                               |\n|  [Background Image / Gradient]                                |\n|                                                               |\n|       [Heading - very large, bold]                            |\n|       [Subheading - medium, regular weight]                   |\n|       [CTA Button - primary, large]                           |\n|                                                               |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |\n| Content | Centered vertically and horizontally, white text |\n| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |\n| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |\n| CTA | Styled as primary button, large. Links to a configured collection or product |\n| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |\n| Padding | Generous vertical and horizontal padding |\n\n**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.\n\n**Accessibility:**\n\n- If background is an image, it is decorative (the content provides meaning)\n- CTA button has descriptive text (e.g., \"Shop the summer collection\")\n\n### 3.2 Featured Collections Section\n\n**Layout:** Section with heading and grid of collection cards.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, large, bold, centered, with generous bottom margin |\n| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |\n| Count | 2 to 4 collections, configurable in theme settings |\n\n**Collection Card:**\n\n```\n+---------------------------+\n|  [Collection Image]       |\n|  3:4 aspect ratio         |\n|  cover-fit, rounded       |\n|                           |\n|  [Title overlay at bottom]|\n|  [Shop now ->]            |\n+---------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |\n| Title | White text overlaid at bottom of image, semi-bold, medium-large size |\n| \"Shop now\" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |\n| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |\n\n**Accessibility:**\n\n- Each card link has the collection title as accessible name\n- Images have alt text matching the collection title\n\n### 3.3 Featured Products Section\n\n**Livewire Component:** FeaturedProducts\n\nThis section uses Livewire for lazy loading to improve initial page load.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, same styling as featured collections heading |\n| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |\n| Products | 4 to 8, configurable. Sourced from a configured collection or \"best sellers\" logic |\n| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |\n\nEach product uses the product-card component (see Section 16).\n\n### 3.4 Newsletter Signup Section\n\n**Livewire Component:** NewsletterSignup\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n|  [Heading: \"Stay in the loop\"]                                |\n|  [Subtext: \"Subscribe for exclusive offers and updates.\"]     |\n|  [Email input] [Subscribe button]                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Light gray (dark mode: darker gray), generous vertical padding |\n| Content | Centered, constrained to a comfortable width |\n| Heading | H2, medium-large, bold |\n| Subtext | Muted color, small top margin |\n| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |\n| Input | Email type, placeholder \"Enter your email\", required |\n| Button | Primary style, \"Subscribe\" text |\n| Success state | Replaces form with \"Thanks for subscribing!\" message |\n| Error state | Inline error below input in red, small text |\n| Loading state | Button text changes to \"Subscribing...\" during submission |\n\n**Accessibility:**\n\n- Label associated with email input (visually hidden if using placeholder)\n- Success/error messages in a polite live region for screen readers\n\n### 3.5 Rich Text Section\n\nSimple section rendering customizable content from theme settings.\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained to comfortable reading width, centered, vertical padding |\n| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |\n| Dark mode | Inverted prose styling |\n\n---\n\n## 4. Collection Page (GET /collections/{handle})\n\n**Livewire Component:** CollectionPage\n\n### 4.1 Collection Header\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > Collections > {Collection Title} |\n| Title | H1, large, bold |\n| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |\n| Spacing | Generous vertical padding |\n\n### 4.2 Toolbar\n\nHorizontal bar between the header and the product grid.\n\n**Desktop:**\n\n```\n+--------------------------------------------------------------+\n| [Filter toggle button]          [X products] [Sort dropdown]  |\n+--------------------------------------------------------------+\n```\n\n**Mobile:**\n\n```\n+--------------------------------------------------------------+\n| [Filter button]                           [Sort dropdown]     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Product count | Small muted text, e.g., \"24 products\" |\n| Sort dropdown | Select element with live Livewire binding |\n\n**Sort Options:**\n\n| Option | Description |\n|---|---|\n| Featured | Default sort order |\n| Price: Low to High | Ascending by price |\n| Price: High to Low | Descending by price |\n| Newest | Descending by creation date |\n| Best Selling | Descending by sales count |\n\n### 4.3 Filter Sidebar\n\n**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.\n**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by \"Filter\" button with filter icon.\n\n**Filter Groups:**\n\nEach filter group is a collapsible section with a heading and toggle chevron.\n\n| Filter Group | Type | Binding | Description |\n|---|---|---|---|\n| Availability | Single checkbox | Live | \"In stock\" toggle |\n| Price Range | Two number inputs | Live with 500ms debounce | \"Min\" and \"Max\" inputs with currency symbol prefix, displayed side by side |\n| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |\n| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |\n\n**Clear All:**\n\n- Link at the top of the filter sidebar: \"Clear all filters\"\n- Only visible when at least one filter is active\n- Small, blue, interactive text\n\n**Active Filter Pills:**\n\n- Displayed above the product grid (below toolbar) when filters are active\n- Each pill: rounded tag with filter value text and a remove (X) button\n- \"Clear all\" link at the end of the pills row\n\n**Mobile Filter Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Position | Full-height overlay from left side |\n| Header | \"Filters\" title + close button (X) |\n| Content | Same filter groups as desktop, stacked vertically |\n| Footer | Sticky \"Apply\" button at bottom that closes the drawer |\n| Clear | \"Clear all\" link in header area |\n| Focus | Trapped within drawer while open |\n\n### 4.4 Product Grid\n\n| Attribute | Description |\n|---|---|\n| Layout | Fills remaining width next to the sidebar |\n| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |\n| Loading state | Subtle opacity reduction on the grid while results update |\n\n### 4.5 Product Card (Component)\n\nThe product card is a reusable component used on collection pages, search results, and featured products sections.\n\n```\n+---------------------------+\n|  [Product Image]          |\n|  1:1 aspect ratio         |\n|  cover-fit, rounded       |\n|  (hover: show 2nd image)  |\n|                           |\n|  [Sale badge]  [Sold out] |\n+---------------------------+\n|  [Product Title]          |\n|  [Price]  [Compare price] |\n|  [Quick Add / Options]    |\n+---------------------------+\n```\n\n**Image Behavior:**\n\n| Attribute | Description |\n|---|---|\n| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |\n| Primary image | Cover-fit, fills the container |\n| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |\n| Fallback | If no image, show a centered shopping bag placeholder icon |\n| Loading | Lazy-loaded |\n","numLines":400,"startLine":100,"totalLines":1835}},"sourceToolAssistantUUID":"35e13348-c1c1-4a42-904f-cac26bd5cb46"}
{"parentUuid":"31683008-c336-41cb-ba73-0a7de1284202","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FdYqyJNyFbmGqpuc5KzwNr","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Let me now read the remaining key sections I need - the checkout section in the storefront UI, and the order detail in admin UI.","signature":"EqkCCkYICxgCKkDHV8CdxCtmMbpwjcn92t2kCFfEVCVRAeS8dlDzV2M9SobelaUMl7h4rIEaStgBniXoS9fhL+b2XQRoTPMYEqDiEgxQLAqU5iI55nM5wGoaDIj6cV0duQWOyn43gCIwdnAqvFwE1nFhWWYBCw5mPYhe5wDUdJCT9keOuD2CsaJjSYvw+6zG8TePwwMaLva9KpABgBeBJSkOxT75hSX3cug6BKwdOdm5TlwR/Qw9GQ0CObrO3d2kSYBt+4rnTFvZE6iMIK/Y00pcvRLQINBq0MHsyO9AQsKtMO1+0ECvtDfjwtNr2bEZQAyT6Ay7sO6ltFi3se4R6BxyamZskSvrr/fF44Moj9E5hF29tXVqET3bKasoOmYO2XA7lxjKhYULpeMHGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":22784,"cache_read_input_tokens":129607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":22784},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VTcmdE6qQYKACGSmYz","type":"assistant","uuid":"dfcfadb2-58ab-4bdc-baf0-97eb948f3a43","timestamp":"2026-02-11T15:08:03.351Z"}
{"parentUuid":"dfcfadb2-58ab-4bdc-baf0-97eb948f3a43","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FdYqyJNyFbmGqpuc5KzwNr","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the checkout UI section and the admin order detail section."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":22784,"cache_read_input_tokens":129607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":22784},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VTcmdE6qQYKACGSmYz","type":"assistant","uuid":"40d0b0e1-c59e-457f-80e8-7950d108fc15","timestamp":"2026-02-11T15:08:03.898Z"}
{"parentUuid":"40d0b0e1-c59e-457f-80e8-7950d108fc15","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FdYqyJNyFbmGqpuc5KzwNr","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FFQoaMR4et3ENSYxebJwKm","name":"Task","input":{"description":"Find checkout and order UI sections","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","subagent_type":"Explore"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":22784,"cache_read_input_tokens":129607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":22784},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VTcmdE6qQYKACGSmYz","type":"assistant","uuid":"f3ff53c6-37d6-456a-82b2-d7c904270d88","timestamp":"2026-02-11T15:08:09.687Z"}
{"parentUuid":"f3ff53c6-37d6-456a-82b2-d7c904270d88","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify."}]},"uuid":"f83c30a0-e1bd-4643-8472-f960f576d188","timestamp":"2026-02-11T15:08:09.689Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"9b6ab6ec-05b3-46c2-a508-ab03c8c3dbe9","timestamp":"2026-02-11T15:08:09.690Z"}
{"parentUuid":"9b6ab6ec-05b3-46c2-a508-ab03c8c3dbe9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:08:36.883Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Ge6aDVkNMYdAxETysmoj3A","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Mn8Gj1A5NzYPoSeqXmQT8R","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":33652,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":33652},"output_tokens":213,"service_tier":"standard","inference_geo":"not_available"},"context_management":{"applied_edits":[]}},"requestId":"req_011CY2VUN9akXwFNaf4p1bwe","uuid":"be9b9855-d397-474e-bf72-c2d4dbf9991d"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"a033910c-9349-4a39-b43d-5aac85f73245","timestamp":"2026-02-11T15:08:36.884Z"}
{"parentUuid":"a033910c-9349-4a39-b43d-5aac85f73245","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:08:36.883Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Ge6aDVkNMYdAxETysmoj3A","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016Um8nSDMVJJrqE1ptqsnj4","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":33652,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":33652},"output_tokens":213,"service_tier":"standard","inference_geo":"not_available"},"context_management":{"applied_edits":[]}},"requestId":"req_011CY2VUN9akXwFNaf4p1bwe","uuid":"18e30533-4879-43f5-9998-bae3371bf032"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"eb6afa40-cb86-4e30-a96b-48f58688c9b7","timestamp":"2026-02-11T15:08:36.884Z"}
{"parentUuid":"eb6afa40-cb86-4e30-a96b-48f58688c9b7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:08:36.883Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Ge6aDVkNMYdAxETysmoj3A","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PJYBNwBX94DeLpQr8KM8Mt","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":33652,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":33652},"output_tokens":213,"service_tier":"standard","inference_geo":"not_available"},"context_management":{"applied_edits":[]}},"requestId":"req_011CY2VUN9akXwFNaf4p1bwe","uuid":"a502f630-10f1-44ee-98ea-1aa8ec80f473"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"ad431035-e6dc-415e-a357-4447d17e24db","timestamp":"2026-02-11T15:08:36.884Z"}
{"parentUuid":"ad431035-e6dc-415e-a357-4447d17e24db","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File content (26427 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_016Um8nSDMVJJrqE1ptqsnj4"}]},"uuid":"ec2b23db-3e5c-4a8b-9046-0ac20bc2a8e8","timestamp":"2026-02-11T15:08:37.084Z","toolUseResult":"Error: File content (26427 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."},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"3e93df91-af5d-4e67-bc6e-1854e95b3302","timestamp":"2026-02-11T15:08:37.084Z"}
{"parentUuid":"3e93df91-af5d-4e67-bc6e-1854e95b3302","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01Mn8Gj1A5NzYPoSeqXmQT8R"}]},"uuid":"fcb91ecf-e57e-41f9-8340-158fac525a8d","timestamp":"2026-02-11T15:08:37.145Z","toolUseResult":"Sibling tool call errored"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"cfa6d682-e97a-4f8b-b14b-05d89c083838","timestamp":"2026-02-11T15:08:37.145Z"}
{"parentUuid":"cfa6d682-e97a-4f8b-b14b-05d89c083838","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_01PJYBNwBX94DeLpQr8KM8Mt"}]},"uuid":"53422d65-953d-46e1-ae94-33257abb270d","timestamp":"2026-02-11T15:08:37.146Z","toolUseResult":"Sibling tool call errored"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"a33353fe-4794-4749-97f3-57dab7930548","timestamp":"2026-02-11T15:08:37.146Z"}
{"parentUuid":"a33353fe-4794-4749-97f3-57dab7930548","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:08:53.678Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01HwhsxMczFNgrHRDf2R8YRQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01M4FgyrsH1XS1cp99TzAF4x","name":"Grep","input":{"pattern":"(?i)(checkout|payment)","path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","output_mode":"content","context":5}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":408,"cache_read_input_tokens":33652,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":408},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VWPcwZiieAzafb5RBy","uuid":"290dfe7e-4353-4304-99f7-a98ced11c757"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"2211eafd-0b3a-4171-9d61-ba4f57734c67","timestamp":"2026-02-11T15:08:53.678Z"}
{"parentUuid":"2211eafd-0b3a-4171-9d61-ba4f57734c67","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01M4FgyrsH1XS1cp99TzAF4x","type":"tool_result","content":"7-## Conventions\n8-\n9-| Convention | Detail |\n10-|---|---|\n11-| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n12:| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n13-| Templating | Blade templates with reusable storefront-prefixed components |\n14-| Accessibility | WCAG 2.2 AA compliance throughout |\n15-| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n16-| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n17-| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n--\n48-\n49-| Category | Purpose | Views |\n50-|---|---|---|\n51-| Layouts | Master page shell | app (master layout) |\n52-| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n53:| Checkout | Purchase flow | checkout index, order confirmation |\n54-| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n55-| Errors | Error states | 404, 503 |\n56-| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n57-\n58-### 1.2 Layout Hierarchy\n--\n191-```\n192-+--------------------------------------------------------------+\n193-| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n194-|                                                               |\n195-| [Social Icons]                                                |\n196:| [Copyright] [Payment Icons]                                   |\n197-+--------------------------------------------------------------+\n198-```\n199-\n200-| Element | Description |\n201-|---|---|\n202-| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n203-| Store info column | Store name, address (if configured), contact email |\n204-| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n205-| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n206:| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n207-| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n208-| Background | Light gray (dark: very dark gray), top border separator |\n209-\n210-### 2.7 Cart Drawer (Livewire)\n211-\n--\n841-|---|---|\n842-| Position | Sticky at the bottom of the drawer, with top border |\n843-| Each line | Flex row, justified space-between, small text |\n844-| Discount line | Only shown when discount applied, green text |\n845-| Total | Slightly larger text, semi-bold |\n846:| Note | \"Shipping and taxes calculated at checkout\" in extra-small muted text below totals |\n847-\n848-### 6.6 Action Buttons\n849-\n850-| Button | Description |\n851-|---|---|\n852:| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |\n853:| Continue shopping | Centered text link below checkout button. Small muted text, hover to darker. Closes the drawer |\n854-\n855-### 6.7 Empty Cart State\n856-\n857-```\n858-+--------------------------------------+\n--\n906-|                                            Subtotal: $84.97   |\n907-|                              [Discount code] [Apply]          |\n908-|                                            Discount: -$12.75  |\n909-|                                            Total:    $72.22   |\n910-|                                                               |\n911:|                                            [Checkout button]  |\n912-|                                            [Continue shopping]|\n913-+--------------------------------------------------------------+\n914-```\n915-\n916-| Attribute | Description |\n--\n924-### 7.2 Totals and Actions\n925-\n926-- Right-aligned on desktop, full-width on mobile\n927-- Same discount code input/apply functionality as drawer\n928-- Same totals breakdown\n929:- \"Checkout\" button: primary, aligned right\n930:- \"Continue shopping\" link: below checkout button\n931-\n932-### 7.3 Empty State\n933-\n934-Same as drawer empty state but centered in the page content area with more vertical padding.\n935-\n936----\n937-\n938:## 8. Checkout Page (GET /checkout/{checkoutId})\n939-\n940:**Livewire Component:** CheckoutPage\n941-\n942-### 8.1 Layout\n943-\n944-**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n945-\n946-**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n947-\n948-```\n949-Desktop:\n950-+-------------------------------+-------------------+\n951:|  [Checkout Form Sections]     | [Order Summary]   |\n952-|  (wider, ~60%)                | (narrower, ~40%)  |\n953-|                               | (sticky)          |\n954-+-------------------------------+-------------------+\n955-\n956-Mobile:\n957-+--------------------------------------------------------------+\n958-|  [Order Summary - collapsible]                                |\n959-+--------------------------------------------------------------+\n960:|  [Checkout Form Sections]                                     |\n961-+--------------------------------------------------------------+\n962-```\n963-\n964:### 8.2 Checkout Steps\n965-\n966:The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.\n967-\n968-**Step State Behavior:**\n969-\n970-| State | Description |\n971-|---|---|\n--\n1037-| Live binding | On selection, the order summary sidebar updates totals |\n1038-| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n1039-\n1040-Continue button: primary.\n1041-\n1042:#### Step 4 - Payment\n1043-\n1044-| Element | Description |\n1045-|---|---|\n1046-| Order review | Brief summary of items, address, and shipping method |\n1047:| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n1048-| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n1049-| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n1050-\n1051-### 8.3 Order Summary Sidebar (Right Column)\n1052-\n--\n1090-| Shipping radios | Grouped in a fieldset with legend |\n1091-| Totals | Live region for screen reader updates |\n1092-\n1093----\n1094-\n1095:## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n1096-\n1097-### 9.1 Layout\n1098-\n1099-Centered content, single column.\n1100-\n--\n1106-|  \"We've sent a confirmation to you@email.com\"                 |\n1107-+--------------------------------------------------------------+\n1108-|  Order Summary                                                |\n1109-|  [Items list with thumbnails]                                 |\n1110-+--------------------------------------------------------------+\n1111:|  Shipping Address          |  Payment Method                  |\n1112-|  Jane Doe                  |  Visa ending in 4242             |\n1113-|  123 Main St               |                                  |\n1114-|  New York, NY 10001        |                                  |\n1115-+--------------------------------------------------------------+\n1116-|  Subtotal        $84.97                                       |\n--\n1133-### 9.2 Order Details\n1134-\n1135-| Element | Description |\n1136-|---|---|\n1137-| Items | List with thumbnail, title, variant info, quantity, price |\n1138:| Address and payment | Two-column grid on desktop, stacked on mobile |\n1139-| Totals | Right-aligned summary |\n1140-\n1141-### 9.3 Actions\n1142-\n1143-| Button | Description |\n--\n1259-+--------------------------------------------------------------+\n1260-|  Items                                                        |\n1261-|  [Thumb] Product Title x2              $49.98                 |\n1262-|          Size: M                                              |\n1263-+--------------------------------------------------------------+\n1264:|  Shipping Address    |  Billing Address   | Payment           |\n1265-|  Jane Doe            |  Same as shipping  | Visa ****4242     |\n1266-|  123 Main St         |                    |                   |\n1267-+--------------------------------------------------------------+\n1268-|  Subtotal        $84.97                                       |\n1269-|  Shipping         $7.99                                       |\n--\n1281-|---|---|\n1282-| Breadcrumbs | Account > Orders > #1042 |\n1283-| Header | Order number and status badges |\n1284-| Date | Small muted text |\n1285-| Items | Table with thumbnail, title + variant info, quantity, line total |\n1286:| Info grid | Three columns on desktop (shipping, billing, payment), stacked on mobile |\n1287-| Totals | Summary breakdown |\n1288-| Fulfillment | Only shown if fulfillments exist. Shows tracking company, number, and link to tracking URL (opens in new tab safely) |\n1289-\n1290-### 10.6 Address Book (GET /account/addresses)\n1291-\n--\n1451-|---|---|---|---|\n1452-| Navigation | Hamburger + drawer | Hamburger + drawer | Horizontal nav bar |\n1453-| Product grid | 2 columns | 3 columns | 3-4 columns |\n1454-| Product page | Stacked (images, then info) | Stacked | Side-by-side 50/50 |\n1455-| Cart drawer | Full-screen overlay | 384px sidebar | 384px sidebar |\n1456:| Checkout | Single column, summary collapsed | Single column | Two columns (form + summary) |\n1457-| Footer | Stacked columns | 2-column grid | 3-4 column grid |\n1458-| Collection filters | Drawer overlay | Drawer overlay | Persistent sidebar |\n1459-| Account tables | Card-based layout | Card-based layout | Table layout |\n1460-\n1461-### Touch Considerations\n--\n1489-| Page | Requirements |\n1490-|---|---|\n1491-| Collection Page | Live region for product count/grid on filter change. Filter sidebar labeled as navigation. Sort dropdown has associated label |\n1492-| Product Page | Variant selector uses fieldset/legend grouping. Price and stock in live regions. Image gallery thumbnails as buttons with current state |\n1493-| Cart Drawer | Dialog role with modal behavior, labeled \"Shopping cart\". Focus trap. Quantity changes in live region |\n1494:| Checkout | Form validation with describedby linking to errors, invalid state on fields. Focus management on step transitions. Shipping radios in fieldset/legend. Totals in live region |\n1495-| Search Modal | Dialog role with modal behavior. Results as listbox with option items. Active descendant tracking for keyboard navigation |\n1496-| Account Pages | All form inputs labeled. Tables use proper header markup with column scope. Status badges readable by text (not color-only) |\n1497-\n1498-### Keyboard Interactions\n1499-\n--\n1589-| address | array or null | null | Pre-filled address data |\n1590-| prefix | string | Empty | Livewire model prefix (e.g., \"shipping\" for shipping.firstName) |\n1591-\n1592-### storefront-order-summary\n1593-\n1594:Renders the checkout order summary sidebar.\n1595-\n1596-| Prop | Type | Default | Description |\n1597-|---|---|---|---|\n1598:| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |\n1599-| showDiscountInput | boolean | true | Whether to show the discount code input |\n1600-\n1601-### storefront-breadcrumbs\n1602-\n1603-Renders a breadcrumb navigation trail.\n--\n1639-| NewsletterSignup | Home page, footer | Email subscription form |\n1640-| CollectionPage | Collection page | Filtering, sorting, pagination |\n1641-| ProductPage | Product page | Variant selection, add to cart |\n1642-| CartDrawer | Layout (global) | Slide-in cart with line management |\n1643-| CartPage | Cart page | Full-page cart |\n1644:| CheckoutPage | Checkout | Multi-step checkout flow |\n1645-| SearchModal | Layout (global) | Autocomplete search overlay |\n1646-| SearchResults | Search page | Full search results with filters |\n1647-| Login | Login page | Authentication form |\n1648-| Register | Register page | Registration form |\n1649-| AddressBook | Addresses page | CRUD for saved addresses |\n--\n1721-    I --> J[Cart Drawer Opens]\n1722-    J --> K{Continue?}\n1723-    K -->|Continue Shopping| B\n1724-    K -->|Apply Discount| L[Enter Discount Code]\n1725-    L --> J\n1726:    K -->|Checkout| M[Checkout Page]\n1727-    M --> N[Step 1: Contact Info]\n1728-    N --> O[Step 2: Shipping Address]\n1729-    O --> P[Step 3: Shipping Method]\n1730:    P --> Q[Step 4: Payment]\n1731-    Q --> R[Stripe/PayPal Redirect]\n1732-    R --> S[Order Confirmation]\n1733-    S --> T{Next?}\n1734-    T -->|Continue Shopping| A\n1735-    T -->|View Order| U[Account Order Detail]\n--\n1773-    I --> J{Valid?}\n1774-    J -->|Yes| K[Discount Applied]\n1775-    K --> B\n1776-    J -->|No| L[Error Message]\n1777-    L --> B\n1778:    C --> M[Proceed to Checkout]\n1779:    M --> N[Checkout Page]\n1780-    C --> O[View Full Cart Page]\n1781-    O --> N\n1782-```\n1783-\n1784-### Component State Diagram\n--\n1790-    state \"Cart Drawer\" as CD {\n1791-        Empty --> HasItems: Add item\n1792-        HasItems --> HasItems: Update quantity\n1793-        HasItems --> HasItems: Apply discount\n1794-        HasItems --> Empty: Remove last item\n1795:        HasItems --> Checkout: Proceed to checkout\n1796-    }\n1797-\n1798-    state \"Product Page\" as PP {\n1799-        NoVariant --> VariantSelected: Select options\n1800-        VariantSelected --> InStock: Stock > 0\n--\n1803-        Adding --> InStock: Complete\n1804-        OutOfStock --> SoldOut: Deny policy\n1805-        OutOfStock --> Backorder: Continue policy\n1806-    }\n1807-\n1808:    state \"Checkout\" as CO {\n1809-        Contact --> ShippingAddress: Complete step 1\n1810-        ShippingAddress --> ShippingMethod: Complete step 2\n1811:        ShippingMethod --> Payment: Complete step 3\n1812:        Payment --> Processing: Submit payment\n1813:        Processing --> Confirmation: Payment success\n1814:        Processing --> Payment: Payment failure\n1815-    }\n1816-```\n1817-\n1818----\n1819-"}]},"uuid":"1f4928a9-0fa7-46a3-906c-aaf4b5cd83ee","timestamp":"2026-02-11T15:08:53.948Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"d9e55cee-5add-48ea-bad5-a53ad0593002","timestamp":"2026-02-11T15:08:54.042Z"}
{"parentUuid":"d9e55cee-5add-48ea-bad5-a53ad0593002","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:08:54.177Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01HwhsxMczFNgrHRDf2R8YRQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VZKMBkDBfB8gaXyEsJAqrq","name":"Grep","input":{"pattern":"(?i)(order.*detail|orders.*show|## order)","path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","output_mode":"content","context":5}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":408,"cache_read_input_tokens":33652,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":408},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VWPcwZiieAzafb5RBy","uuid":"558261c5-e8b1-42d6-8f35-b29dd49c77d6"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"40ba4a8c-bae4-47dc-8882-70c5b6f7d7c5","timestamp":"2026-02-11T15:08:54.178Z"}
{"parentUuid":"40ba4a8c-bae4-47dc-8882-70c5b6f7d7c5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VZKMBkDBfB8gaXyEsJAqrq","type":"tool_result","content":"283-- A large heading with the formatted number value\n284-- A row with a Flux badge showing percentage change (green if positive, red if negative) and a directional arrow icon\n285-\n286-Each tile shows a loading state (reduced opacity) while data refreshes.\n287-\n288:### Orders Chart\n289-\n290-A card below the KPI tiles containing:\n291-- A Flux heading (size lg): \"Orders over time\"\n292-- A Chart.js canvas (line or bar chart) showing daily order counts\n293-- Chart.js initialized via Alpine.js; data passed from Livewire\n--\n911-\n912-### Table Column Definitions\n913-\n914-| Column | Content |\n915-|--------|---------|\n916:| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n917-| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n918-| Customer | Customer name or \"Guest\" |\n919-| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n920-| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n921-| Total | Formatted currency amount |\n--\n931-| Status badges | `badge` |\n932-| Cell text | `text` |\n933-\n934----\n935-\n936:## 8. Order Detail\n937-\n938-**Route:** `GET /admin/orders/{order}`\n939-\n940:**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n941-\n942:**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n943-\n944-### Component Requirements\n945-\n946-| Property | Type | Purpose |\n947-|----------|------|---------|\n--\n998-+----------------------------------------------+--------------------+\n999-```\n1000-\n1001-### Left Column Sections\n1002-\n1003:#### Order Heading\n1004-- Order number as a large heading\n1005-- Financial status badge (color mapped: paid=green, default=zinc)\n1006-- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n1007-- Placed-at date as muted text\n1008-\n--\n1014-#### Timeline\n1015-A vertical timeline with left border line and dot markers:\n1016-- Each event: title text (bold) and timestamp text (small, muted)\n1017-- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n1018-\n1019:#### Order Lines Table\n1020-\n1021-| Column | Content |\n1022-|--------|---------|\n1023-| Image | 40x40 product thumbnail |\n1024-| Product | Title and SKU |\n1025-| Quantity | Unit count |\n1026-| Unit Price | Formatted amount |\n1027-| Total | Line total formatted |\n1028-\n1029:#### Order Summary\n1030-A right-aligned summary block below the lines table:\n1031-- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n1032-- Each row is a flex container with label left and amount right\n1033-\n1034-#### Payment Details\n--\n1178-+----------------------------------------------+--------------------+\n1179-```\n1180-\n1181-**Left column:**\n1182-- Customer info card: Name, email, created date, marketing opt-in status (Flux badge)\n1183:- Order history table: columns Order #, Date, Status, Total. Each order links to order detail. Paginated.\n1184-\n1185-**Right column:**\n1186-- Addresses card: List of addresses, each showing label, full address text, default badge (if `is_default`), Edit and Delete buttons\n1187-- \"Add address\" button opens address modal\n1188-\n--\n2380-| `App\\Livewire\\Admin\\Products\\Form` | `livewire.admin.products.form` | `GET /admin/products/create`, `GET /admin/products/{product}/edit` |\n2381-| `App\\Livewire\\Admin\\Collections\\Index` | `livewire.admin.collections.index` | `GET /admin/collections` |\n2382-| `App\\Livewire\\Admin\\Collections\\Form` | `livewire.admin.collections.form` | `GET /admin/collections/create`, `GET /admin/collections/{collection}/edit` |\n2383-| `App\\Livewire\\Admin\\Inventory\\Index` | `livewire.admin.inventory.index` | `GET /admin/inventory` |\n2384-| `App\\Livewire\\Admin\\Orders\\Index` | `livewire.admin.orders.index` | `GET /admin/orders` |\n2385:| `App\\Livewire\\Admin\\Orders\\Show` | `livewire.admin.orders.show` | `GET /admin/orders/{order}` |\n2386-| `App\\Livewire\\Admin\\Customers\\Index` | `livewire.admin.customers.index` | `GET /admin/customers` |\n2387-| `App\\Livewire\\Admin\\Customers\\Show` | `livewire.admin.customers.show` | `GET /admin/customers/{customer}` |\n2388-| `App\\Livewire\\Admin\\Discounts\\Index` | `livewire.admin.discounts.index` | `GET /admin/discounts` |\n2389-| `App\\Livewire\\Admin\\Discounts\\Form` | `livewire.admin.discounts.form` | `GET /admin/discounts/create`, `GET /admin/discounts/{discount}/edit` |\n2390-| `App\\Livewire\\Admin\\Settings\\General` | `livewire.admin.settings.general` | `GET /admin/settings` |"}]},"uuid":"0095eca1-4d93-4a06-89e0-b2a0dc7c8993","timestamp":"2026-02-11T15:08:54.443Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"40305696-9f82-4408-8a73-cac8445a1add","timestamp":"2026-02-11T15:08:54.463Z"}
{"parentUuid":"40305696-9f82-4408-8a73-cac8445a1add","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:08:54.557Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01HwhsxMczFNgrHRDf2R8YRQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XtJkNPvbjiBCRFNMcfzk7F","name":"Grep","input":{"pattern":"(?i)(checkout|payment)","path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","output_mode":"content","context":5}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":408,"cache_read_input_tokens":33652,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":408},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VWPcwZiieAzafb5RBy","uuid":"ed0b5edd-06cb-49f1-8229-4698676655ba"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"77e798f6-9dfa-46dd-8eb7-0ce2404fb3b6","timestamp":"2026-02-11T15:08:54.557Z"}
{"parentUuid":"77e798f6-9dfa-46dd-8eb7-0ce2404fb3b6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XtJkNPvbjiBCRFNMcfzk7F","type":"tool_result","content":"<persisted-output>\nOutput too large (21.1KB). Full output saved to: /Users/fabianwesner/.claude/projects/-Users-fabianwesner-Herd-shop/17a2a457-7a36-4d51-927a-aa26d07a423b/tool-results/toolu_01XtJkNPvbjiBCRFNMcfzk7F.txt\n\nPreview (first 2KB):\n1-# API and Routing Specification\n2-\n3:This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n4-\n5-All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n6-\n7----\n8-\n--\n66-#### Orders\n67-\n68-| Method | Path | Description | Component |\n69-|--------|------|-------------|-----------|\n70-| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n71:| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n72-\n73-#### Customers\n74-\n75-| Method | Path | Description | Component |\n76-|--------|------|-------------|-----------|\n--\n87-\n88-#### Settings\n89-\n90-| Method | Path | Description | Component |\n91-|--------|------|-------------|-----------|\n92:| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n93-| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n94-| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n95-\n96-#### Themes and Online Store\n97-\n--\n181-| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n182-| POST | `/reset-password` | Reset password | Handled by Livewire action |\n183-\n184----\n185-\n186:### 1.4 Checkout Routes\n187-\n188-**Middleware:** `web`, `store.resolve`\n189-\n190-| Method | Path | Description | Component |\n191-|--------|------|-------------|-----------|\n...\n</persisted-output>"}]},"uuid":"7ff51488-8fc0-4ed1-96c6-23dd708386af","timestamp":"2026-02-11T15:08:54.817Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"5e992777-6113-4f7f-9f14-eea476b59196","timestamp":"2026-02-11T15:08:54.818Z"}
{"parentUuid":"5e992777-6113-4f7f-9f14-eea476b59196","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:08:57.998Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01RyLC8GHHn8FoQaSxgGjNw8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TPLP2ocGB1zGECSgQCRwmE","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":186,"limit":400}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":6709,"cache_read_input_tokens":34060,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6709},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VXh6gu4QcarBBRt8Qh","uuid":"b196fec0-32bb-484c-90ca-32f88cc9141c"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"7be6dddf-10c0-4125-b5ba-64bb0d6a8b7d","timestamp":"2026-02-11T15:08:57.998Z"}
{"parentUuid":"7be6dddf-10c0-4125-b5ba-64bb0d6a8b7d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TPLP2ocGB1zGECSgQCRwmE","type":"tool_result","content":"   186→### 1.4 Checkout Routes\n   187→\n   188→**Middleware:** `web`, `store.resolve`\n   189→\n   190→| Method | Path | Description | Component |\n   191→|--------|------|-------------|-----------|\n   192→| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n   193→| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n   194→\n   195→---\n   196→\n   197→## 2. Storefront REST API\n   198→\n   199→**Prefix:** `/api/storefront/v1`\n   200→**Middleware:** `store.resolve`, `throttle:api.storefront`\n   201→**Content-Type:** `application/json`\n   202→\n   203→These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n   204→\n   205→---\n   206→\n   207→### 2.1 Cart Endpoints\n   208→\n   209→#### POST /api/storefront/v1/carts\n   210→\n   211→Create a new cart.\n   212→\n   213→- **Authentication:** None (guest) or session-based (logged-in customer)\n   214→- **Rate limit:** `api.storefront` (120/min per IP)\n   215→\n   216→**Request body:**\n   217→\n   218→```json\n   219→{\n   220→  \"currency\": \"EUR\"\n   221→}\n   222→```\n   223→\n   224→| Field | Type | Required | Validation |\n   225→|-------|------|----------|------------|\n   226→| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n   227→\n   228→**Success response:** `201 Created`\n   229→\n   230→```json\n   231→{\n   232→  \"id\": 42,\n   233→  \"store_id\": 1,\n   234→  \"customer_id\": null,\n   235→  \"currency\": \"EUR\",\n   236→  \"cart_version\": 1,\n   237→  \"status\": \"active\",\n   238→  \"lines\": [],\n   239→  \"totals\": {\n   240→    \"subtotal\": 0,\n   241→    \"discount\": 0,\n   242→    \"total\": 0,\n   243→    \"currency\": \"EUR\"\n   244→  },\n   245→  \"created_at\": \"2026-02-11T10:00:00Z\",\n   246→  \"updated_at\": \"2026-02-11T10:00:00Z\"\n   247→}\n   248→```\n   249→\n   250→**Error responses:**\n   251→- `422` - Validation error (invalid currency)\n   252→\n   253→---\n   254→\n   255→#### GET /api/storefront/v1/carts/{cartId}\n   256→\n   257→Retrieve a cart with all lines and computed totals.\n   258→\n   259→- **Authentication:** None (cart ID acts as token)\n   260→- **Rate limit:** `api.storefront` (120/min per IP)\n   261→\n   262→**URL parameters:**\n   263→\n   264→| Parameter | Type | Required | Description |\n   265→|-----------|------|----------|-------------|\n   266→| `cartId` | integer | Yes | The cart identifier |\n   267→\n   268→**Success response:** `200 OK`\n   269→\n   270→```json\n   271→{\n   272→  \"id\": 42,\n   273→  \"store_id\": 1,\n   274→  \"customer_id\": null,\n   275→  \"currency\": \"EUR\",\n   276→  \"cart_version\": 3,\n   277→  \"status\": \"active\",\n   278→  \"lines\": [\n   279→    {\n   280→      \"id\": 201,\n   281→      \"variant_id\": 101,\n   282→      \"product_title\": \"Classic T-Shirt\",\n   283→      \"variant_title\": \"Blue / Medium\",\n   284→      \"sku\": \"TSH-BLU-M\",\n   285→      \"quantity\": 2,\n   286→      \"unit_price_amount\": 2500,\n   287→      \"line_subtotal_amount\": 5000,\n   288→      \"line_discount_amount\": 0,\n   289→      \"line_total_amount\": 5000,\n   290→      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n   291→      \"requires_shipping\": true,\n   292→      \"available_quantity\": 50\n   293→    }\n   294→  ],\n   295→  \"totals\": {\n   296→    \"subtotal\": 5000,\n   297→    \"discount\": 0,\n   298→    \"total\": 5000,\n   299→    \"currency\": \"EUR\",\n   300→    \"line_count\": 1,\n   301→    \"item_count\": 2\n   302→  },\n   303→  \"created_at\": \"2026-02-11T10:00:00Z\",\n   304→  \"updated_at\": \"2026-02-11T10:05:00Z\"\n   305→}\n   306→```\n   307→\n   308→**Error responses:**\n   309→- `404` - Cart not found\n   310→\n   311→---\n   312→\n   313→#### POST /api/storefront/v1/carts/{cartId}/lines\n   314→\n   315→Add a line item to the cart.\n   316→\n   317→- **Authentication:** None\n   318→- **Rate limit:** `api.storefront` (120/min per IP)\n   319→\n   320→**Request body:**\n   321→\n   322→```json\n   323→{\n   324→  \"variant_id\": 101,\n   325→  \"quantity\": 2\n   326→}\n   327→```\n   328→\n   329→| Field | Type | Required | Validation |\n   330→|-------|------|----------|------------|\n   331→| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n   332→| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n   333→\n   334→**Success response:** `201 Created`\n   335→\n   336→Returns the full cart object (same schema as GET /carts/{cartId}).\n   337→\n   338→**Error responses:**\n   339→- `404` - Cart not found\n   340→- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n   341→- `422` - Validation error\n   342→\n   343→```json\n   344→{\n   345→  \"message\": \"The given data was invalid.\",\n   346→  \"errors\": {\n   347→    \"variant_id\": [\"The selected variant is out of stock.\"]\n   348→  }\n   349→}\n   350→```\n   351→\n   352→---\n   353→\n   354→#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n   355→\n   356→Update the quantity of a cart line.\n   357→\n   358→- **Authentication:** None\n   359→- **Rate limit:** `api.storefront` (120/min per IP)\n   360→\n   361→**Request body:**\n   362→\n   363→```json\n   364→{\n   365→  \"quantity\": 3,\n   366→  \"cart_version\": 3\n   367→}\n   368→```\n   369→\n   370→| Field | Type | Required | Validation |\n   371→|-------|------|----------|------------|\n   372→| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n   373→| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n   374→\n   375→**Success response:** `200 OK`\n   376→\n   377→Returns the full cart object with updated totals.\n   378→\n   379→**Error responses:**\n   380→- `404` - Cart or line not found\n   381→- `409` - Version conflict\n   382→- `422` - Validation error (e.g., exceeds inventory)\n   383→\n   384→---\n   385→\n   386→#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n   387→\n   388→Remove a line item from the cart.\n   389→\n   390→- **Authentication:** None\n   391→- **Rate limit:** `api.storefront` (120/min per IP)\n   392→\n   393→**Request body:**\n   394→\n   395→```json\n   396→{\n   397→  \"cart_version\": 3\n   398→}\n   399→```\n   400→\n   401→| Field | Type | Required | Validation |\n   402→|-------|------|----------|------------|\n   403→| `cart_version` | integer | Yes | Must match current cart version. |\n   404→\n   405→**Success response:** `200 OK`\n   406→\n   407→Returns the full cart object.\n   408→\n   409→**Error responses:**\n   410→- `404` - Cart or line not found\n   411→- `409` - Version conflict\n   412→\n   413→---\n   414→\n   415→### 2.2 Checkout Endpoints\n   416→\n   417→#### Checkout Flow Sequence Diagram\n   418→\n   419→```mermaid\n   420→sequenceDiagram\n   421→    participant C as Customer Browser\n   422→    participant API as Storefront API\n   423→    participant Stripe as Stripe API\n   424→    participant WH as Webhook Handler\n   425→\n   426→    C->>API: POST /checkouts (cart_id, email)\n   427→    API-->>C: 201 Checkout (status: started)\n   428→\n   429→    C->>API: PUT /checkouts/{id}/address\n   430→    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n   431→\n   432→    C->>API: PUT /checkouts/{id}/shipping-method\n   433→    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n   434→\n   435→    opt Apply discount\n   436→        C->>API: POST /checkouts/{id}/apply-discount\n   437→        API-->>C: 200 Checkout (totals recalculated)\n   438→    end\n   439→\n   440→    alt Stripe Checkout Session\n   441→        C->>API: POST /payments/stripe/checkout-session\n   442→        API->>Stripe: Create Checkout Session\n   443→        Stripe-->>API: session_url\n   444→        API-->>C: 201 (session_url)\n   445→        C->>Stripe: Redirect to hosted payment page\n   446→        Stripe-->>C: Redirect to success_url\n   447→        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   448→        WH-->>Stripe: 200 OK\n   449→    else Stripe Elements (embedded)\n   450→        C->>API: POST /payments/stripe/payment-intent\n   451→        API->>Stripe: Create PaymentIntent\n   452→        Stripe-->>API: client_secret\n   453→        API-->>C: 201 (client_secret)\n   454→        C->>Stripe: Confirm payment (client-side)\n   455→        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   456→        WH-->>Stripe: 200 OK\n   457→    else PayPal\n   458→        C->>API: POST /payments/paypal/create-order\n   459→        API-->>C: 201 (approval_url)\n   460→        C->>C: Redirect to PayPal approval\n   461→        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n   462→        WH-->>C: 200 OK\n   463→    end\n   464→\n   465→    C->>API: GET /checkouts/{id} (poll or redirect)\n   466→    API-->>C: 200 Checkout (status: completed)\n   467→```\n   468→\n   469→#### POST /api/storefront/v1/checkouts\n   470→\n   471→Create a checkout from a cart.\n   472→\n   473→- **Authentication:** None or session-based\n   474→- **Rate limit:** `checkout` (10/min per session)\n   475→\n   476→**Request body:**\n   477→\n   478→```json\n   479→{\n   480→  \"cart_id\": 42,\n   481→  \"email\": \"customer@example.com\"\n   482→}\n   483→```\n   484→\n   485→| Field | Type | Required | Validation |\n   486→|-------|------|----------|------------|\n   487→| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n   488→| `email` | string | Yes | Valid email address. |\n   489→\n   490→**Success response:** `201 Created`\n   491→\n   492→```json\n   493→{\n   494→  \"id\": 7,\n   495→  \"store_id\": 1,\n   496→  \"cart_id\": 42,\n   497→  \"customer_id\": null,\n   498→  \"status\": \"started\",\n   499→  \"email\": \"customer@example.com\",\n   500→  \"shipping_address_json\": null,\n   501→  \"billing_address_json\": null,\n   502→  \"shipping_method_id\": null,\n   503→  \"discount_code\": null,\n   504→  \"lines\": [\n   505→    {\n   506→      \"variant_id\": 101,\n   507→      \"product_title\": \"Classic T-Shirt\",\n   508→      \"variant_title\": \"Blue / Medium\",\n   509→      \"sku\": \"TSH-BLU-M\",\n   510→      \"quantity\": 2,\n   511→      \"unit_price_amount\": 2500,\n   512→      \"line_total_amount\": 5000\n   513→    }\n   514→  ],\n   515→  \"totals\": {\n   516→    \"subtotal\": 5000,\n   517→    \"discount\": 0,\n   518→    \"shipping\": 0,\n   519→    \"tax\": 0,\n   520→    \"total\": 5000,\n   521→    \"currency\": \"EUR\"\n   522→  },\n   523→  \"available_shipping_methods\": [],\n   524→  \"expires_at\": \"2026-02-12T10:00:00Z\",\n   525→  \"created_at\": \"2026-02-11T10:00:00Z\"\n   526→}\n   527→```\n   528→\n   529→**Error responses:**\n   530→- `404` - Cart not found\n   531→- `422` - Validation errors (empty cart, invalid email)\n   532→\n   533→---\n   534→\n   535→#### GET /api/storefront/v1/checkouts/{checkoutId}\n   536→\n   537→Retrieve the current checkout state and computed totals.\n   538→\n   539→- **Authentication:** None (checkout ID acts as token)\n   540→- **Rate limit:** `checkout` (10/min per session)\n   541→\n   542→**Success response:** `200 OK`\n   543→\n   544→Returns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n   545→\n   546→**Error responses:**\n   547→- `404` - Checkout not found\n   548→- `410` - Checkout expired\n   549→\n   550→---\n   551→\n   552→#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n   553→\n   554→Set the shipping and/or billing address on a checkout.\n   555→\n   556→- **Authentication:** None\n   557→- **Rate limit:** `checkout` (10/min per session)\n   558→\n   559→**Request body:**\n   560→\n   561→```json\n   562→{\n   563→  \"shipping_address\": {\n   564→    \"first_name\": \"Jane\",\n   565→    \"last_name\": \"Doe\",\n   566→    \"address1\": \"123 Main St\",\n   567→    \"address2\": \"Apt 4B\",\n   568→    \"city\": \"Berlin\",\n   569→    \"province\": \"Berlin\",\n   570→    \"province_code\": \"BE\",\n   571→    \"country\": \"DE\",\n   572→    \"country_code\": \"DE\",\n   573→    \"postal_code\": \"10115\",\n   574→    \"phone\": \"+49301234567\"\n   575→  },\n   576→  \"billing_address\": null,\n   577→  \"use_shipping_as_billing\": true\n   578→}\n   579→```\n   580→\n   581→**Shipping address fields:**\n   582→\n   583→| Field | Type | Required | Validation |\n   584→|-------|------|----------|------------|\n   585→| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |"}]},"uuid":"8ca51ed8-7b34-4a01-b19e-230382aca06f","timestamp":"2026-02-11T15:08:58.001Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"e4781dc9-6e5a-4c43-9dac-c644d0ae37df","timestamp":"2026-02-11T15:08:58.052Z"}
{"parentUuid":"e4781dc9-6e5a-4c43-9dac-c644d0ae37df","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:02.590Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01E911YgvgQ9dhcDE3fRxf5M","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016W3ZDTsUng7Jxe1ARuLaaB","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":585,"limit":500}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":4632,"cache_read_input_tokens":40769,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4632},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VXxHkEsYaHi9r5AxiS","uuid":"b0dd96d3-dddb-445f-bbc4-d5bbc7a3fae2"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"9e6b6b39-2621-45b2-a563-8c247975792f","timestamp":"2026-02-11T15:09:02.590Z"}
{"parentUuid":"9e6b6b39-2621-45b2-a563-8c247975792f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016W3ZDTsUng7Jxe1ARuLaaB","type":"tool_result","content":"   585→| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n   586→| `shipping_address.first_name` | string | Yes | Max: 255 |\n   587→| `shipping_address.last_name` | string | Yes | Max: 255 |\n   588→| `shipping_address.address1` | string | Yes | Max: 500 |\n   589→| `shipping_address.address2` | string | No | Max: 500 |\n   590→| `shipping_address.city` | string | Yes | Max: 255 |\n   591→| `shipping_address.province` | string | No | Max: 255 |\n   592→| `shipping_address.province_code` | string | No | Max: 10 |\n   593→| `shipping_address.country` | string | Yes | Max: 255 |\n   594→| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n   595→| `shipping_address.postal_code` | string | Yes | Max: 20 |\n   596→| `shipping_address.phone` | string | No | Max: 50 |\n   597→| `billing_address` | object | No | Same fields as shipping_address |\n   598→| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n   599→\n   600→**Success response:** `200 OK`\n   601→\n   602→Returns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n   603→\n   604→```json\n   605→{\n   606→  \"id\": 7,\n   607→  \"status\": \"addressed\",\n   608→  \"shipping_address_json\": {\n   609→    \"first_name\": \"Jane\",\n   610→    \"last_name\": \"Doe\",\n   611→    \"address1\": \"123 Main St\",\n   612→    \"address2\": \"Apt 4B\",\n   613→    \"city\": \"Berlin\",\n   614→    \"province\": \"Berlin\",\n   615→    \"province_code\": \"BE\",\n   616→    \"country\": \"DE\",\n   617→    \"country_code\": \"DE\",\n   618→    \"postal_code\": \"10115\",\n   619→    \"phone\": \"+49301234567\"\n   620→  },\n   621→  \"billing_address_json\": {\n   622→    \"first_name\": \"Jane\",\n   623→    \"last_name\": \"Doe\",\n   624→    \"address1\": \"123 Main St\",\n   625→    \"address2\": \"Apt 4B\",\n   626→    \"city\": \"Berlin\",\n   627→    \"province\": \"Berlin\",\n   628→    \"province_code\": \"BE\",\n   629→    \"country\": \"DE\",\n   630→    \"country_code\": \"DE\",\n   631→    \"postal_code\": \"10115\",\n   632→    \"phone\": \"+49301234567\"\n   633→  },\n   634→  \"available_shipping_methods\": [\n   635→    {\n   636→      \"id\": 1,\n   637→      \"name\": \"Standard Shipping\",\n   638→      \"type\": \"flat\",\n   639→      \"price_amount\": 500,\n   640→      \"currency\": \"EUR\",\n   641→      \"estimated_days_min\": 3,\n   642→      \"estimated_days_max\": 5\n   643→    },\n   644→    {\n   645→      \"id\": 2,\n   646→      \"name\": \"Express Shipping\",\n   647→      \"type\": \"flat\",\n   648→      \"price_amount\": 1200,\n   649→      \"currency\": \"EUR\",\n   650→      \"estimated_days_min\": 1,\n   651→      \"estimated_days_max\": 2\n   652→    }\n   653→  ],\n   654→  \"totals\": {\n   655→    \"subtotal\": 5000,\n   656→    \"discount\": 0,\n   657→    \"shipping\": 0,\n   658→    \"tax\": 0,\n   659→    \"total\": 5000,\n   660→    \"currency\": \"EUR\"\n   661→  }\n   662→}\n   663→```\n   664→\n   665→**Error responses:**\n   666→- `404` - Checkout not found\n   667→- `410` - Checkout expired\n   668→- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n   669→\n   670→---\n   671→\n   672→#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n   673→\n   674→Select a shipping method for the checkout.\n   675→\n   676→- **Authentication:** None\n   677→- **Rate limit:** `checkout` (10/min per session)\n   678→\n   679→**Request body:**\n   680→\n   681→```json\n   682→{\n   683→  \"shipping_method_id\": 1\n   684→}\n   685→```\n   686→\n   687→| Field | Type | Required | Validation |\n   688→|-------|------|----------|------------|\n   689→| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n   690→\n   691→**Success response:** `200 OK`\n   692→\n   693→Returns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n   694→\n   695→```json\n   696→{\n   697→  \"id\": 7,\n   698→  \"status\": \"shipping_selected\",\n   699→  \"shipping_method_id\": 1,\n   700→  \"totals\": {\n   701→    \"subtotal\": 5000,\n   702→    \"discount\": 0,\n   703→    \"shipping\": 500,\n   704→    \"tax\": 1045,\n   705→    \"total\": 6545,\n   706→    \"currency\": \"EUR\"\n   707→  },\n   708→  \"tax_provider_snapshot_json\": {\n   709→    \"provider\": \"manual\",\n   710→    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n   711→    \"lines\": [\n   712→      {\n   713→        \"variant_id\": 101,\n   714→        \"tax_amount\": 950,\n   715→        \"rate\": 1900,\n   716→        \"jurisdiction\": \"DE\"\n   717→      }\n   718→    ],\n   719→    \"shipping_tax_amount\": 95,\n   720→    \"shipping_tax_rate\": 1900\n   721→  }\n   722→}\n   723→```\n   724→\n   725→**Error responses:**\n   726→- `404` - Checkout not found\n   727→- `410` - Checkout expired\n   728→- `422` - Invalid shipping method (not available for this address/checkout)\n   729→\n   730→---\n   731→\n   732→#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n   733→\n   734→Apply a discount code to the checkout.\n   735→\n   736→- **Authentication:** None\n   737→- **Rate limit:** `checkout` (10/min per session)\n   738→\n   739→**Request body:**\n   740→\n   741→```json\n   742→{\n   743→  \"code\": \"WELCOME10\"\n   744→}\n   745→```\n   746→\n   747→| Field | Type | Required | Validation |\n   748→|-------|------|----------|------------|\n   749→| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n   750→\n   751→**Success response:** `200 OK`\n   752→\n   753→```json\n   754→{\n   755→  \"id\": 7,\n   756→  \"status\": \"shipping_selected\",\n   757→  \"discount_code\": \"WELCOME10\",\n   758→  \"totals\": {\n   759→    \"subtotal\": 5000,\n   760→    \"discount\": 500,\n   761→    \"shipping\": 500,\n   762→    \"tax\": 950,\n   763→    \"total\": 5950,\n   764→    \"currency\": \"EUR\"\n   765→  },\n   766→  \"applied_discounts\": [\n   767→    {\n   768→      \"code\": \"WELCOME10\",\n   769→      \"type\": \"percent\",\n   770→      \"value_amount\": 10,\n   771→      \"applied_amount\": 500,\n   772→      \"description\": \"10% off your first order\"\n   773→    }\n   774→  ]\n   775→}\n   776→```\n   777→\n   778→**Error responses:**\n   779→- `400` - Discount code expired (error code: `discount_expired`)\n   780→- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n   781→- `404` - Checkout not found\n   782→- `410` - Checkout expired\n   783→- `422` - Invalid code (not found, does not apply to these items)\n   784→\n   785→```json\n   786→{\n   787→  \"message\": \"This discount code has expired.\",\n   788→  \"error_code\": \"discount_expired\"\n   789→}\n   790→```\n   791→\n   792→---\n   793→\n   794→#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n   795→\n   796→Remove the applied discount code from the checkout.\n   797→\n   798→- **Authentication:** None\n   799→- **Rate limit:** `checkout` (10/min per session)\n   800→\n   801→**Success response:** `200 OK`\n   802→\n   803→Returns the full checkout object with discount removed and totals recalculated.\n   804→\n   805→**Error responses:**\n   806→- `404` - Checkout not found or no discount applied\n   807→- `410` - Checkout expired\n   808→\n   809→---\n   810→\n   811→### 2.3 Payment Endpoints\n   812→\n   813→#### Payment Flow Sequence Diagram (Stripe)\n   814→\n   815→```mermaid\n   816→sequenceDiagram\n   817→    participant C as Customer Browser\n   818→    participant API as Storefront API\n   819→    participant Stripe as Stripe\n   820→    participant WH as Webhook Receiver\n   821→    participant Order as Order System\n   822→\n   823→    C->>API: POST /payments/stripe/payment-intent\n   824→    API->>Stripe: Create PaymentIntent (amount, currency, metadata)\n   825→    Stripe-->>API: PaymentIntent (client_secret)\n   826→    API-->>C: 201 {client_secret, payment_intent_id, amount, currency}\n   827→\n   828→    C->>Stripe: confirmCardPayment(client_secret)\n   829→    Stripe-->>C: Payment result\n   830→\n   831→    Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   832→    WH->>WH: Verify Stripe-Signature header\n   833→    WH->>WH: Check idempotency (event.id)\n   834→    WH->>Order: Create order, decrement inventory, send confirmation\n   835→    WH-->>Stripe: 200 {received: true}\n   836→```\n   837→\n   838→#### POST /api/storefront/v1/payments/stripe/checkout-session\n   839→\n   840→Create a Stripe-hosted Checkout Session for the checkout. Redirects the user to Stripe's hosted payment page.\n   841→\n   842→- **Authentication:** None\n   843→- **Rate limit:** `checkout` (10/min per session)\n   844→\n   845→**Request body:**\n   846→\n   847→```json\n   848→{\n   849→  \"checkout_id\": 7,\n   850→  \"success_url\": \"https://acme.test/checkout/7/confirmation\",\n   851→  \"cancel_url\": \"https://acme.test/checkout/7\"\n   852→}\n   853→```\n   854→\n   855→| Field | Type | Required | Validation |\n   856→|-------|------|----------|------------|\n   857→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   858→| `success_url` | string | Yes | Valid URL. |\n   859→| `cancel_url` | string | Yes | Valid URL. |\n   860→\n   861→**Success response:** `201 Created`\n   862→\n   863→```json\n   864→{\n   865→  \"session_id\": \"cs_live_abc123def456\",\n   866→  \"session_url\": \"https://checkout.stripe.com/c/pay/cs_live_abc123def456\",\n   867→  \"expires_at\": \"2026-02-11T10:30:00Z\"\n   868→}\n   869→```\n   870→\n   871→**Error responses:**\n   872→- `404` - Checkout not found\n   873→- `409` - Checkout not in a valid state for payment\n   874→- `422` - Validation errors\n   875→- `502` - Stripe API error\n   876→\n   877→---\n   878→\n   879→#### POST /api/storefront/v1/payments/stripe/payment-intent\n   880→\n   881→Create a Stripe PaymentIntent for embedded payment form (Stripe Elements).\n   882→\n   883→- **Authentication:** None\n   884→- **Rate limit:** `checkout` (10/min per session)\n   885→\n   886→**Request body:**\n   887→\n   888→```json\n   889→{\n   890→  \"checkout_id\": 7\n   891→}\n   892→```\n   893→\n   894→| Field | Type | Required | Validation |\n   895→|-------|------|----------|------------|\n   896→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   897→\n   898→**Success response:** `201 Created`\n   899→\n   900→```json\n   901→{\n   902→  \"client_secret\": \"pi_abc123_secret_def456\",\n   903→  \"payment_intent_id\": \"pi_abc123\",\n   904→  \"amount\": 6545,\n   905→  \"currency\": \"eur\"\n   906→}\n   907→```\n   908→\n   909→**Error responses:**\n   910→- `404` - Checkout not found\n   911→- `409` - Checkout not in a valid state for payment\n   912→- `502` - Stripe API error\n   913→\n   914→---\n   915→\n   916→#### POST /api/storefront/v1/payments/paypal/create-order\n   917→\n   918→Create a PayPal order for the checkout.\n   919→\n   920→- **Authentication:** None\n   921→- **Rate limit:** `checkout` (10/min per session)\n   922→\n   923→**Request body:**\n   924→\n   925→```json\n   926→{\n   927→  \"checkout_id\": 7,\n   928→  \"return_url\": \"https://acme.test/checkout/7/confirmation\",\n   929→  \"cancel_url\": \"https://acme.test/checkout/7\"\n   930→}\n   931→```\n   932→\n   933→| Field | Type | Required | Validation |\n   934→|-------|------|----------|------------|\n   935→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   936→| `return_url` | string | Yes | Valid URL. |\n   937→| `cancel_url` | string | Yes | Valid URL. |\n   938→\n   939→**Success response:** `201 Created`\n   940→\n   941→```json\n   942→{\n   943→  \"paypal_order_id\": \"5O190127TN364715T\",\n   944→  \"approval_url\": \"https://www.paypal.com/checkoutnow?token=5O190127TN364715T\",\n   945→  \"status\": \"CREATED\"\n   946→}\n   947→```\n   948→\n   949→**Error responses:**\n   950→- `404` - Checkout not found\n   951→- `409` - Checkout not in a valid state for payment\n   952→- `422` - Validation errors\n   953→- `502` - PayPal API error\n   954→\n   955→---\n   956→\n   957→### 2.4 Order Endpoints\n   958→\n   959→#### GET /api/storefront/v1/orders/{orderNumber}\n   960→\n   961→Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n   962→\n   963→- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n   964→- **Rate limit:** `api.storefront` (120/min per IP)\n   965→\n   966→**Query parameters:**\n   967→\n   968→| Parameter | Type | Required | Description |\n   969→|-----------|------|----------|-------------|\n   970→| `token` | string | Yes | HMAC-signed access token for this order |\n   971→\n   972→**Success response:** `200 OK`\n   973→\n   974→```json\n   975→{\n   976→  \"order_number\": \"#1001\",\n   977→  \"status\": \"paid\",\n   978→  \"financial_status\": \"paid\",\n   979→  \"fulfillment_status\": \"unfulfilled\",\n   980→  \"email\": \"customer@example.com\",\n   981→  \"currency\": \"EUR\",\n   982→  \"placed_at\": \"2026-02-11T10:10:00Z\",\n   983→  \"lines\": [\n   984→    {\n   985→      \"title_snapshot\": \"Classic T-Shirt\",\n   986→      \"variant_title\": \"Blue / Medium\",\n   987→      \"sku_snapshot\": \"TSH-BLU-M\",\n   988→      \"quantity\": 2,\n   989→      \"unit_price_amount\": 2500,\n   990→      \"total_amount\": 5000\n   991→    }\n   992→  ],\n   993→  \"totals\": {\n   994→    \"subtotal_amount\": 5000,\n   995→    \"discount_amount\": 500,\n   996→    \"shipping_amount\": 500,\n   997→    \"tax_amount\": 950,\n   998→    \"total_amount\": 5950\n   999→  },\n  1000→  \"shipping_address\": {\n  1001→    \"first_name\": \"Jane\",\n  1002→    \"last_name\": \"Doe\",\n  1003→    \"address1\": \"123 Main St\",\n  1004→    \"city\": \"Berlin\",\n  1005→    \"country\": \"DE\",\n  1006→    \"postal_code\": \"10115\"\n  1007→  },\n  1008→  \"fulfillments\": []\n  1009→}\n  1010→```\n  1011→\n  1012→**Error responses:**\n  1013→- `401` - Invalid or missing token\n  1014→- `404` - Order not found\n  1015→\n  1016→---\n  1017→\n  1018→### 2.5 Search Endpoints\n  1019→\n  1020→#### GET /api/storefront/v1/search\n  1021→\n  1022→Search products within the current store.\n  1023→\n  1024→- **Authentication:** None\n  1025→- **Rate limit:** `search` (30/min per IP)\n  1026→\n  1027→**Query parameters:**\n  1028→\n  1029→| Parameter | Type | Required | Description |\n  1030→|-----------|------|----------|-------------|\n  1031→| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n  1032→| `filters` | json | No | URL-encoded JSON object with filter facets. |\n  1033→| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n  1034→| `page` | integer | No | Page number. Default: 1. |\n  1035→| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n  1036→\n  1037→**Filters JSON schema:**\n  1038→\n  1039→```json\n  1040→{\n  1041→  \"collection_id\": 5,\n  1042→  \"price_min\": 1000,\n  1043→  \"price_max\": 5000,\n  1044→  \"in_stock\": true,\n  1045→  \"tags\": [\"organic\", \"cotton\"],\n  1046→  \"vendor\": \"Acme Apparel\"\n  1047→}\n  1048→```\n  1049→\n  1050→**Success response:** `200 OK`\n  1051→\n  1052→```json\n  1053→{\n  1054→  \"query\": \"t-shirt\",\n  1055→  \"results\": [\n  1056→    {\n  1057→      \"id\": 10,\n  1058→      \"title\": \"Classic T-Shirt\",\n  1059→      \"handle\": \"classic-t-shirt\",\n  1060→      \"vendor\": \"Acme Apparel\",\n  1061→      \"product_type\": \"Apparel\",\n  1062→      \"price_amount\": 2500,\n  1063→      \"compare_at_amount\": 3500,\n  1064→      \"currency\": \"EUR\",\n  1065→      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n  1066→      \"in_stock\": true,\n  1067→      \"tags\": [\"organic\", \"cotton\"]\n  1068→    }\n  1069→  ],\n  1070→  \"facets\": {\n  1071→    \"vendors\": [\n  1072→      { \"value\": \"Acme Apparel\", \"count\": 12 }\n  1073→    ],\n  1074→    \"tags\": [\n  1075→      { \"value\": \"organic\", \"count\": 8 },\n  1076→      { \"value\": \"cotton\", \"count\": 15 }\n  1077→    ],\n  1078→    \"price_range\": {\n  1079→      \"min\": 1500,\n  1080→      \"max\": 8500\n  1081→    }\n  1082→  },\n  1083→  \"pagination\": {\n  1084→    \"current_page\": 1,"}]},"uuid":"9ea36e00-4ec9-4585-82e9-9d353bdd92aa","timestamp":"2026-02-11T15:09:02.597Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"b6a1c092-816f-4ea3-b373-36ce093f5186","timestamp":"2026-02-11T15:09:02.615Z"}
{"parentUuid":"b6a1c092-816f-4ea3-b373-36ce093f5186","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:06.293Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Wm7tosyQVgWd1xWHKHPASW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UuWCgRsYiDoEJeD3Vij5JF","name":"Bash","input":{"command":"grep -n \"^## 8\\.\" /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md | head -5","description":"Find line number for checkout section in 04-STOREFRONT-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":6032,"cache_read_input_tokens":45401,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6032},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VYGX7F44zBkzPe8hAF","uuid":"c0ca2517-b2ce-41f9-a3dd-f0a1a3a5d91d"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"5460cae5-d0c8-409e-aaf3-96f2594b855b","timestamp":"2026-02-11T15:09:06.294Z"}
{"parentUuid":"5460cae5-d0c8-409e-aaf3-96f2594b855b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UuWCgRsYiDoEJeD3Vij5JF","type":"tool_result","content":"938:## 8. Checkout Page (GET /checkout/{checkoutId})","is_error":false}]},"uuid":"e536a075-cdf6-4c7d-adf8-7131d4013a54","timestamp":"2026-02-11T15:09:06.375Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"ca19a539-ba90-41f1-ba19-c27a475163e0","timestamp":"2026-02-11T15:09:06.520Z"}
{"parentUuid":"ca19a539-ba90-41f1-ba19-c27a475163e0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:06.740Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Wm7tosyQVgWd1xWHKHPASW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FYnwwoSRGVuBxWkLJjMFAn","name":"Bash","input":{"command":"grep -n \"^## 8\\.\" /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md | head -5","description":"Find line number for order detail section in 03-ADMIN-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":6032,"cache_read_input_tokens":45401,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6032},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VYGX7F44zBkzPe8hAF","uuid":"272f7aa2-5bfe-4d65-a2ce-5d0600e60875"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"36bbd1f0-32c3-4509-a4f8-61cece179d66","timestamp":"2026-02-11T15:09:06.740Z"}
{"parentUuid":"36bbd1f0-32c3-4509-a4f8-61cece179d66","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FYnwwoSRGVuBxWkLJjMFAn","type":"tool_result","content":"936:## 8. Order Detail","is_error":false}]},"uuid":"3aafe48e-af77-41be-a2a4-d81b9ff1471a","timestamp":"2026-02-11T15:09:06.809Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"e7b1bde2-6538-4a38-a3f3-2ca4f3d20401","timestamp":"2026-02-11T15:09:07.095Z"}
{"parentUuid":"e7b1bde2-6538-4a38-a3f3-2ca4f3d20401","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:07.344Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Wm7tosyQVgWd1xWHKHPASW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019XiUjKeCEWQctCczNaVHnz","name":"Bash","input":{"command":"grep -n \"^### 2\\.[23]\" /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","description":"Find line numbers for checkout and payment sections in 02-API-ROUTES.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":6032,"cache_read_input_tokens":45401,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6032},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VYGX7F44zBkzPe8hAF","uuid":"bc7f7af0-48bc-426a-b9d6-fb73e63658dd"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"8bded0ea-aa4a-43b4-be96-48c126c74299","timestamp":"2026-02-11T15:09:07.344Z"}
{"parentUuid":"8bded0ea-aa4a-43b4-be96-48c126c74299","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019XiUjKeCEWQctCczNaVHnz","type":"tool_result","content":"415:### 2.2 Checkout Endpoints\n811:### 2.3 Payment Endpoints","is_error":false}]},"uuid":"3c825bd1-ba2b-4df3-99e1-c3fa8e047fbe","timestamp":"2026-02-11T15:09:07.419Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"2cfa490f-071a-423f-b7d4-f7b67a6792e8","timestamp":"2026-02-11T15:09:07.420Z"}
{"parentUuid":"2cfa490f-071a-423f-b7d4-f7b67a6792e8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:11.693Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011zHgQ8tCUiTw3tsQhK5jiY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ae94yZznmVxDvoqfSoxtNH","name":"Bash","input":{"command":"wc -l /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","description":"Get total line counts for files"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":636,"cache_read_input_tokens":51433,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":636},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VYd6cUxMep49T98S4y","uuid":"ef51f008-442c-4db9-a312-554aeeaa352f"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"a4dc1980-7e98-4649-9eaa-7ac11cfd86d9","timestamp":"2026-02-11T15:09:11.693Z"}
{"parentUuid":"a4dc1980-7e98-4649-9eaa-7ac11cfd86d9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ae94yZznmVxDvoqfSoxtNH","type":"tool_result","content":"    1834 /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md\n    2402 /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md\n    4236 total","is_error":false}]},"uuid":"4a50cdae-aae0-4164-a114-b66527d94722","timestamp":"2026-02-11T15:09:11.773Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"46660410-622f-4eac-b508-557560ea0ecd","timestamp":"2026-02-11T15:09:12.042Z"}
{"parentUuid":"46660410-622f-4eac-b508-557560ea0ecd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:12.243Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011zHgQ8tCUiTw3tsQhK5jiY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CwYLRz81rVYcL3cQAGehV5","name":"Bash","input":{"command":"sed -n '938,1000p' /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md | grep -n \"^## 9\\|^---\" | head -3","description":"Find where checkout section ends in 04-STOREFRONT-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":636,"cache_read_input_tokens":51433,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":636},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VYd6cUxMep49T98S4y","uuid":"450a1680-4953-4ae6-8854-ed031c52594e"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"803bb92f-405b-4717-989d-85c7734964c3","timestamp":"2026-02-11T15:09:12.244Z"}
{"parentUuid":"803bb92f-405b-4717-989d-85c7734964c3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CwYLRz81rVYcL3cQAGehV5","type":"tool_result","content":"","is_error":false}]},"uuid":"26d5da08-58b0-4e3b-8c95-4920f949e990","timestamp":"2026-02-11T15:09:12.315Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"94a0fdc5-a2e0-45d1-b21d-dbe052fbef2e","timestamp":"2026-02-11T15:09:12.530Z"}
{"parentUuid":"94a0fdc5-a2e0-45d1-b21d-dbe052fbef2e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:12.712Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011zHgQ8tCUiTw3tsQhK5jiY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01S6t4x5sgamK4PfJgbFk7vd","name":"Bash","input":{"command":"sed -n '936,1100p' /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md | grep -n \"^## 9\\|^---\" | head -3","description":"Find where order detail section ends in 03-ADMIN-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":636,"cache_read_input_tokens":51433,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":636},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VYd6cUxMep49T98S4y","uuid":"187d64c4-bb58-4225-b770-88bf16bb63b8"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"eb1de8e9-de43-4a60-baef-2d6cb76fce40","timestamp":"2026-02-11T15:09:12.712Z"}
{"parentUuid":"eb1de8e9-de43-4a60-baef-2d6cb76fce40","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01S6t4x5sgamK4PfJgbFk7vd","type":"tool_result","content":"","is_error":false}]},"uuid":"3a8838f3-e97a-4593-8d63-c33d15cfb1b6","timestamp":"2026-02-11T15:09:12.785Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"07a17e76-79fc-4ff0-afeb-02c730beee90","timestamp":"2026-02-11T15:09:12.786Z"}
{"parentUuid":"07a17e76-79fc-4ff0-afeb-02c730beee90","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:17.788Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_013GmbaPJwusbQqhXREfQ7WS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019FDHxX3CfiiZSKGXkRNtBr","name":"Bash","input":{"command":"sed -n '938,1100p' /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md | tail -40","description":"View end of checkout section area"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":569,"cache_read_input_tokens":52069,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":569},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VZ23zBnTB41CSwKp1i","uuid":"31ed2b87-b28f-4274-b23f-3561bbb7f66c"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"1e84036b-fbcc-46ee-9b17-c961f0641d05","timestamp":"2026-02-11T15:09:17.789Z"}
{"parentUuid":"1e84036b-fbcc-46ee-9b17-c961f0641d05","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019FDHxX3CfiiZSKGXkRNtBr","type":"tool_result","content":"|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.","is_error":false}]},"uuid":"9dc289f0-4b98-46c2-8043-60b6cb1ba3ac","timestamp":"2026-02-11T15:09:17.870Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"663a42c4-4ced-484a-98ab-92d10e61fa73","timestamp":"2026-02-11T15:09:17.967Z"}
{"parentUuid":"663a42c4-4ced-484a-98ab-92d10e61fa73","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:18.153Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_013GmbaPJwusbQqhXREfQ7WS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ULkzwqt8hGnHBPHtPpezAA","name":"Bash","input":{"command":"grep -n \"^## 9\" /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","description":"Find section 9 start"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":569,"cache_read_input_tokens":52069,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":569},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VZ23zBnTB41CSwKp1i","uuid":"4324a6a3-dc1b-4c3b-b33c-6267853cf22d"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"8a113ff2-0a10-43ae-bb90-a7788a629cf3","timestamp":"2026-02-11T15:09:18.153Z"}
{"parentUuid":"8a113ff2-0a10-43ae-bb90-a7788a629cf3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ULkzwqt8hGnHBPHtPpezAA","type":"tool_result","content":"1095:## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)","is_error":false}]},"uuid":"a781e815-470e-47db-acae-203deba1cfa9","timestamp":"2026-02-11T15:09:18.222Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"655d1afc-9aa4-446a-8770-704d0bd12ab1","timestamp":"2026-02-11T15:09:18.464Z"}
{"parentUuid":"655d1afc-9aa4-446a-8770-704d0bd12ab1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:18.645Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_013GmbaPJwusbQqhXREfQ7WS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Sn56ijPq4m1giYjQxPKkhu","name":"Bash","input":{"command":"grep -n \"^## 9\\|^## 10\" /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md | head -2","description":"Find section boundaries in admin UI"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":569,"cache_read_input_tokens":52069,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":569},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VZ23zBnTB41CSwKp1i","uuid":"29153b19-27ba-4e0f-bdc7-e75a20bd6799"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"522f0db9-2d50-4bdd-9b9b-14d1b8a388f3","timestamp":"2026-02-11T15:09:18.646Z"}
{"parentUuid":"522f0db9-2d50-4bdd-9b9b-14d1b8a388f3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Sn56ijPq4m1giYjQxPKkhu","type":"tool_result","content":"1108:## 9. Customers List and Detail\n1207:## 10. Discounts List and Form","is_error":false}]},"uuid":"38168fa2-8ee0-4dbc-b755-47728cbef943","timestamp":"2026-02-11T15:09:18.714Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"994b3a73-3bc4-4ccf-8320-bbb90e9211c7","timestamp":"2026-02-11T15:09:18.715Z"}
{"parentUuid":"994b3a73-3bc4-4ccf-8320-bbb90e9211c7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"assistant","timestamp":"2026-02-11T15:09:22.243Z","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01YBEm8uXYmVeiDRtPoBSELJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01L5YUi9Af1rgogLvzBu2rS4","name":"Bash","input":{"command":"grep -n \"^### 2\\.[4-9]\\|^## 3\" /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md | head -5","description":"Find what comes after payment section"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":854,"cache_read_input_tokens":52638,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":854},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"requestId":"req_011CY2VZTPEsShAw1n525fJr","uuid":"b3584b4e-a97e-4ea2-bd0c-3ff93d6f4bbd"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"fe90b110-4043-4030-83a0-681e2609324f","timestamp":"2026-02-11T15:09:22.243Z"}
{"parentUuid":"fe90b110-4043-4030-83a0-681e2609324f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01L5YUi9Af1rgogLvzBu2rS4","type":"tool_result","content":"957:### 2.4 Order Endpoints\n1018:### 2.5 Search Endpoints\n1142:### 2.6 Analytics Endpoints\n1207:## 3. Admin REST API","is_error":false}]},"uuid":"e04f719a-4d2b-46fe-be55-050e81ce738e","timestamp":"2026-02-11T15:09:22.323Z"},"normalizedMessages":[],"type":"agent_progress","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c"},"toolUseID":"agent_msg_01FdYqyJNyFbmGqpuc5KzwNr","parentToolUseID":"toolu_01FFQoaMR4et3ENSYxebJwKm","uuid":"cec721ec-1818-4e0a-a69e-e941f9da529e","timestamp":"2026-02-11T15:09:22.324Z"}
{"parentUuid":"f3ff53c6-37d6-456a-82b2-d7c904270d88","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FFQoaMR4et3ENSYxebJwKm","type":"tool_result","content":[{"type":"text","text":"Excellent! Now I have all the information. Let me compile the complete report:\n\n## Spec File Sections - Complete Report\n\nHere are the exact line numbers and summaries for the three sections you need:\n\n---\n\n### 1. **04-STOREFRONT-UI.md - Checkout Section**\n\n**Lines:** 938-1094\n\n**Section Name:** `## 8. Checkout Page`\n\n**Summary:**\nThis section describes the complete checkout page UI (`GET /checkout/{checkoutId}`) implemented as a Livewire component. It covers:\n- Two-column desktop layout (60/40 form + summary) vs single-column mobile with collapsible summary\n- 4-step checkout flow: Contact Info, Shipping Address, Shipping Method, Payment\n- Step state behavior (collapsed, active, completed)\n- Order summary sidebar with live totals updates\n- Payment step with \"Pay now\" button, Stripe Checkout redirect, PayPal button option, and Stripe Elements embedding\n- Accessibility requirements for form validation, focus management, fieldsets/legends, and live regions\n\n---\n\n### 2. **03-ADMIN-UI.md - Order Detail/Show Section**\n\n**Lines:** 936-1107\n\n**Section Name:** `## 8. Order Detail`\n\n**Summary:**\nThis section details the admin order detail page (`GET /admin/orders/{order}`) rendered via Livewire component `App\\Livewire\\Admin\\Orders\\Show`. It includes:\n- Two-column layout: left (order timeline, lines table, summary, payment/shipping details) and right (customer profile, addresses, fulfillment card)\n- Order heading with status badges (financial + fulfillment status)\n- Timeline showing events (Order placed, Payment received, Fulfillment, Shipped, Delivered, Refunded)\n- Order lines table (image, product title/SKU, quantity, unit price, line total)\n- Summary block with subtotal, discount, shipping, tax, total\n- Payment details card showing method, amount, date, status\n- Shipping/billing address cards with full address details\n- Fulfillments card (if applicable) with tracking info\n- Customer profile section with name, email, signup date, marketing opt-in badge\n- Address book with saved customer addresses (CRUD buttons)\n\n---\n\n### 3. **02-API-ROUTES.md - Checkout & Payment Sections**\n\n**Checkout Endpoints:**\n**Lines:** 415-809\n\n**Section Name:** `### 2.2 Checkout Endpoints`\n\n**Summary:**\nDefines 5 checkout flow endpoints:\n- `POST /checkouts` - Create checkout from cart (returns `201` with status `started`)\n- `GET /checkouts/{checkoutId}` - Retrieve current checkout state and totals\n- `PUT /checkouts/{checkoutId}/address` - Set shipping/billing addresses (status transitions to `addressed`, returns available shipping methods)\n- `PUT /checkouts/{checkoutId}/shipping-method` - Select shipping method (status to `shipping_selected`, recalculates totals with tax)\n- `POST /checkouts/{checkoutId}/apply-discount` - Apply discount code\n- `DELETE /checkouts/{checkoutId}/discount` - Remove discount\n\nIncludes detailed sequence diagram showing full checkout flow through to Stripe/PayPal redirect.\n\n---\n\n**Payment Endpoints:**\n**Lines:** 811-956\n\n**Section Name:** `### 2.3 Payment Endpoints`\n\n**Summary:**\nDefines 3 payment method endpoints for processing payments:\n- `POST /payments/stripe/checkout-session` - Create Stripe-hosted Checkout Session, returns `session_url` for redirect\n- `POST /payments/stripe/payment-intent` - Create Stripe PaymentIntent for embedded form (Stripe Elements), returns `client_secret`\n- `POST /payments/paypal/create-order` - Create PayPal order, returns `approval_url` for redirect\n\nIncludes Stripe payment flow sequence diagram showing PaymentIntent creation, client-side confirmation, and webhook handling for order creation.\n\nAll payment endpoints require checkout in `shipping_selected` or `payment_pending` status and are rate-limited to 10/min per session.\n\n---\n\nThese are the three main areas you'll need to modify for your implementation. Let me know if you need more details about any specific section!"},{"type":"text","text":"agentId: a3a9b0c (for resuming to continue this agent's work if needed)\n<usage>total_tokens: 54836\ntool_uses: 18\nduration_ms: 85437</usage>"}]}]},"uuid":"f2fed744-09e3-4df7-a9e2-f3bdc8ee5ada","timestamp":"2026-02-11T15:09:35.126Z","toolUseResult":{"status":"completed","prompt":"Search the following spec files for the sections I need:\n\n1. In `/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md` - find the checkout section (search for \"Checkout\" or \"Payment\" section)\n2. In `/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md` - find the Order detail/show section (search for \"Order\" detail or \"Orders Show\")\n3. In `/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md` - find the checkout endpoints and payment endpoints sections (search for \"checkout\" and \"payment\" sections after line 415)\n\nFor each, tell me the exact line numbers where these sections start and end, and give me a summary of what they contain. I need this to know what to modify.","agentId":"a3a9b0c","content":[{"type":"text","text":"Excellent! Now I have all the information. Let me compile the complete report:\n\n## Spec File Sections - Complete Report\n\nHere are the exact line numbers and summaries for the three sections you need:\n\n---\n\n### 1. **04-STOREFRONT-UI.md - Checkout Section**\n\n**Lines:** 938-1094\n\n**Section Name:** `## 8. Checkout Page`\n\n**Summary:**\nThis section describes the complete checkout page UI (`GET /checkout/{checkoutId}`) implemented as a Livewire component. It covers:\n- Two-column desktop layout (60/40 form + summary) vs single-column mobile with collapsible summary\n- 4-step checkout flow: Contact Info, Shipping Address, Shipping Method, Payment\n- Step state behavior (collapsed, active, completed)\n- Order summary sidebar with live totals updates\n- Payment step with \"Pay now\" button, Stripe Checkout redirect, PayPal button option, and Stripe Elements embedding\n- Accessibility requirements for form validation, focus management, fieldsets/legends, and live regions\n\n---\n\n### 2. **03-ADMIN-UI.md - Order Detail/Show Section**\n\n**Lines:** 936-1107\n\n**Section Name:** `## 8. Order Detail`\n\n**Summary:**\nThis section details the admin order detail page (`GET /admin/orders/{order}`) rendered via Livewire component `App\\Livewire\\Admin\\Orders\\Show`. It includes:\n- Two-column layout: left (order timeline, lines table, summary, payment/shipping details) and right (customer profile, addresses, fulfillment card)\n- Order heading with status badges (financial + fulfillment status)\n- Timeline showing events (Order placed, Payment received, Fulfillment, Shipped, Delivered, Refunded)\n- Order lines table (image, product title/SKU, quantity, unit price, line total)\n- Summary block with subtotal, discount, shipping, tax, total\n- Payment details card showing method, amount, date, status\n- Shipping/billing address cards with full address details\n- Fulfillments card (if applicable) with tracking info\n- Customer profile section with name, email, signup date, marketing opt-in badge\n- Address book with saved customer addresses (CRUD buttons)\n\n---\n\n### 3. **02-API-ROUTES.md - Checkout & Payment Sections**\n\n**Checkout Endpoints:**\n**Lines:** 415-809\n\n**Section Name:** `### 2.2 Checkout Endpoints`\n\n**Summary:**\nDefines 5 checkout flow endpoints:\n- `POST /checkouts` - Create checkout from cart (returns `201` with status `started`)\n- `GET /checkouts/{checkoutId}` - Retrieve current checkout state and totals\n- `PUT /checkouts/{checkoutId}/address` - Set shipping/billing addresses (status transitions to `addressed`, returns available shipping methods)\n- `PUT /checkouts/{checkoutId}/shipping-method` - Select shipping method (status to `shipping_selected`, recalculates totals with tax)\n- `POST /checkouts/{checkoutId}/apply-discount` - Apply discount code\n- `DELETE /checkouts/{checkoutId}/discount` - Remove discount\n\nIncludes detailed sequence diagram showing full checkout flow through to Stripe/PayPal redirect.\n\n---\n\n**Payment Endpoints:**\n**Lines:** 811-956\n\n**Section Name:** `### 2.3 Payment Endpoints`\n\n**Summary:**\nDefines 3 payment method endpoints for processing payments:\n- `POST /payments/stripe/checkout-session` - Create Stripe-hosted Checkout Session, returns `session_url` for redirect\n- `POST /payments/stripe/payment-intent` - Create Stripe PaymentIntent for embedded form (Stripe Elements), returns `client_secret`\n- `POST /payments/paypal/create-order` - Create PayPal order, returns `approval_url` for redirect\n\nIncludes Stripe payment flow sequence diagram showing PaymentIntent creation, client-side confirmation, and webhook handling for order creation.\n\nAll payment endpoints require checkout in `shipping_selected` or `payment_pending` status and are rate-limited to 10/min per session.\n\n---\n\nThese are the three main areas you'll need to modify for your implementation. Let me know if you need more details about any specific section!"}],"totalDurationMs":85437,"totalTokens":54836,"totalToolUseCount":18,"usage":{"input_tokens":3,"cache_creation_input_tokens":347,"cache_read_input_tokens":53492,"output_tokens":994,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":347,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"sourceToolAssistantUUID":"f3ff53c6-37d6-456a-82b2-d7c904270d88"}
{"parentUuid":"f2fed744-09e3-4df7-a9e2-f3bdc8ee5ada","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Xj81ZWEbE6V752yoevEm2a","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me read those specific sections."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1407,"cache_read_input_tokens":152391,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1407},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VafeToq2bNaKtDoRDk","type":"assistant","uuid":"4322709a-4ed3-4d60-af2e-811fcd5059f7","timestamp":"2026-02-11T15:09:37.915Z"}
{"parentUuid":"4322709a-4ed3-4d60-af2e-811fcd5059f7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Xj81ZWEbE6V752yoevEm2a","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LncvDujyXKrRnEgLHGUVnT","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","offset":938,"limit":160}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1407,"cache_read_input_tokens":152391,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1407},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VafeToq2bNaKtDoRDk","type":"assistant","uuid":"38e6dad4-377a-4c7d-95fe-f4a1f17de31b","timestamp":"2026-02-11T15:09:38.767Z"}
{"parentUuid":"38e6dad4-377a-4c7d-95fe-f4a1f17de31b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01LncvDujyXKrRnEgLHGUVnT","toolUseID":"toolu_01LncvDujyXKrRnEgLHGUVnT","timestamp":"2026-02-11T15:09:38.771Z","uuid":"7b7ade60-aafc-4d35-b775-0b0f0dc78e0c"}
{"parentUuid":"38e6dad4-377a-4c7d-95fe-f4a1f17de31b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LncvDujyXKrRnEgLHGUVnT","type":"tool_result","content":"   938→## 8. Checkout Page (GET /checkout/{checkoutId})\n   939→\n   940→**Livewire Component:** CheckoutPage\n   941→\n   942→### 8.1 Layout\n   943→\n   944→**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n   945→\n   946→**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n   947→\n   948→```\n   949→Desktop:\n   950→+-------------------------------+-------------------+\n   951→|  [Checkout Form Sections]     | [Order Summary]   |\n   952→|  (wider, ~60%)                | (narrower, ~40%)  |\n   953→|                               | (sticky)          |\n   954→+-------------------------------+-------------------+\n   955→\n   956→Mobile:\n   957→+--------------------------------------------------------------+\n   958→|  [Order Summary - collapsible]                                |\n   959→+--------------------------------------------------------------+\n   960→|  [Checkout Form Sections]                                     |\n   961→+--------------------------------------------------------------+\n   962→```\n   963→\n   964→### 8.2 Checkout Steps\n   965→\n   966→The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.\n   967→\n   968→**Step State Behavior:**\n   969→\n   970→| State | Description |\n   971→|---|---|\n   972→| Completed | Shows summary of entered data with an \"Edit\" link |\n   973→| Current | Expanded with the full form |\n   974→| Future | Collapsed, muted, not interactive |\n   975→\n   976→Each step header shows the step number + title, e.g., \"1. Contact information\".\n   977→\n   978→#### Step 1 - Contact Information\n   979→\n   980→| Field | Description |\n   981→|---|---|\n   982→| Email | Full-width email input, required. Validated on blur |\n   983→| Account link | \"Already have an account? Log in\" link below email, small blue text |\n   984→| Continue button | Primary, below the form |\n   985→\n   986→#### Step 2 - Shipping Address\n   987→\n   988→Uses the address-form component.\n   989→\n   990→**Fields:**\n   991→\n   992→| Field | Type | Required | Layout Width |\n   993→|---|---|---|---|\n   994→| First name | Text | Yes | Half |\n   995→| Last name | Text | Yes | Half |\n   996→| Address line 1 | Text | Yes | Full |\n   997→| Address line 2 | Text | No | Full |\n   998→| City | Text | Yes | Half |\n   999→| State/Province | Select or text | Yes (varies by country) | Half |\n  1000→| Postal code | Text | Yes | Half |\n  1001→| Country | Select | Yes | Half |\n  1002→| Phone | Tel | No | Full |\n  1003→\n  1004→- Layout: 2-column grid, full-width fields span both columns\n  1005→- Country select: on change, State/Province updates to show relevant regions\n  1006→- All fields validate on blur\n  1007→\n  1008→**Billing address:**\n  1009→\n  1010→- Checkbox: \"Billing address same as shipping\" (checked by default)\n  1011→- When unchecked, a second address form appears for billing\n  1012→\n  1013→**Saved addresses (logged-in customers):**\n  1014→\n  1015→- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n  1016→- \"Use a new address\" option to show the blank form\n  1017→- On selection, form fields populate with the saved address data\n  1018→\n  1019→Continue button: primary.\n  1020→\n  1021→#### Step 3 - Shipping Method\n  1022→\n  1023→Radio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n  1024→\n  1025→```\n  1026→+--------------------------------------------------------------+\n  1027→| (o) Standard Shipping          5-7 business days       $7.99 |\n  1028→| ( ) Express Shipping           2-3 business days      $14.99 |\n  1029→| ( ) Free Shipping              7-10 business days      Free  |\n  1030→+--------------------------------------------------------------+\n  1031→```\n  1032→\n  1033→| Attribute | Description |\n  1034→|---|---|\n  1035→| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n  1036→| Label layout | Method name (left), estimated delivery (center), price (right) |\n  1037→| Live binding | On selection, the order summary sidebar updates totals |\n  1038→| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n  1039→\n  1040→Continue button: primary.\n  1041→\n  1042→#### Step 4 - Payment\n  1043→\n  1044→| Element | Description |\n  1045→|---|---|\n  1046→| Order review | Brief summary of items, address, and shipping method |\n  1047→| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n  1048→| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n  1049→| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n  1050→\n  1051→### 8.3 Order Summary Sidebar (Right Column)\n  1052→\n  1053→Sticky on desktop.\n  1054→\n  1055→```\n  1056→+-------------------------------+\n  1057→|  Order Summary                |\n  1058→+-------------------------------+\n  1059→|  [Thumb] Title x2     $49.98 |\n  1060→|          Size: M              |\n  1061→|  [Thumb] Title x1     $34.99 |\n  1062→+-------------------------------+\n  1063→|  [Discount code] [Apply]     |\n  1064→+-------------------------------+\n  1065→|  Subtotal              $84.97|\n  1066→|  Discount             -$12.75|\n  1067→|  Shipping               $7.99|\n  1068→|  Tax                    $5.74|\n  1069→|  ----------------------------|\n  1070→|  Total                 $85.95|\n  1071→+-------------------------------+\n  1072→```\n  1073→\n  1074→| Element | Description |\n  1075→|---|---|\n  1076→| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n  1077→| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n  1078→| Discount code input | Same as cart drawer |\n  1079→| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n  1080→| Updates | Event-driven from step completion actions (no polling) |\n  1081→\n  1082→### 8.4 Accessibility\n  1083→\n  1084→| Requirement | Description |\n  1085→|---|---|\n  1086→| Labels | All form inputs have associated label elements |\n  1087→| Validation errors | Linked via describedby pointing to error message element |\n  1088→| Required fields | Marked with required attribute and visual asterisk |\n  1089→| Step transitions | Focus moves to the first input of the newly expanded step |\n  1090→| Shipping radios | Grouped in a fieldset with legend |\n  1091→| Totals | Live region for screen reader updates |\n  1092→\n  1093→---\n  1094→\n  1095→## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n  1096→\n  1097→### 9.1 Layout"}]},"uuid":"de9f2d58-9c7c-4e78-8ea5-b6a9c25f19cb","timestamp":"2026-02-11T15:09:38.770Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment\n\n| Element | Description |\n|---|---|\n| Order review | Brief summary of items, address, and shipping method |\n| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout","numLines":160,"startLine":938,"totalLines":1835}},"sourceToolAssistantUUID":"38e6dad4-377a-4c7d-95fe-f4a1f17de31b"}
{"parentUuid":"de9f2d58-9c7c-4e78-8ea5-b6a9c25f19cb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Xj81ZWEbE6V752yoevEm2a","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QgQNMcB7ELjr5iiCRwwemh","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","offset":936,"limit":180}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1407,"cache_read_input_tokens":152391,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1407},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VafeToq2bNaKtDoRDk","type":"assistant","uuid":"4279dc8c-5985-4682-8514-c7a69843ce75","timestamp":"2026-02-11T15:09:39.422Z"}
{"parentUuid":"4279dc8c-5985-4682-8514-c7a69843ce75","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01QgQNMcB7ELjr5iiCRwwemh","toolUseID":"toolu_01QgQNMcB7ELjr5iiCRwwemh","timestamp":"2026-02-11T15:09:39.425Z","uuid":"6b9ba695-f005-474e-aec8-26f4c5985949"}
{"parentUuid":"4279dc8c-5985-4682-8514-c7a69843ce75","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QgQNMcB7ELjr5iiCRwwemh","type":"tool_result","content":"   936→## 8. Order Detail\n   937→\n   938→**Route:** `GET /admin/orders/{order}`\n   939→\n   940→**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n   941→\n   942→**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n   943→\n   944→### Component Requirements\n   945→\n   946→| Property | Type | Purpose |\n   947→|----------|------|---------|\n   948→| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n   949→| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n   950→| `trackingCompany` | `string` | Tracking company for fulfillment |\n   951→| `trackingNumber` | `string` | Tracking number |\n   952→| `trackingUrl` | `string` | Tracking URL |\n   953→| `refundAmount` | `?int` | Custom refund amount |\n   954→| `refundReason` | `string` | Refund reason text |\n   955→| `refundLines` | `array` | Lines selected for refund |\n   956→\n   957→| Action | Behavior |\n   958→|--------|----------|\n   959→| `mount(Order $order)` | Loads order with all relationships |\n   960→| `createFulfillment()` | Creates a fulfillment record for selected lines |\n   961→| `createRefund()` | Creates a refund for selected lines or custom amount |\n   962→| `openFulfillmentModal()` | Opens the fulfillment modal |\n   963→| `openRefundModal()` | Opens the refund modal |\n   964→\n   965→### Two-Column Layout Wireframe\n   966→\n   967→```\n   968→+----------------------------------------------+--------------------+\n   969→|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n   970→+----------------------------------------------+--------------------+\n   971→| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n   972→| Jan 15, 2024 2:30 PM                        | John Doe           |\n   973→|                                              | john@example.com   |\n   974→| [Create fulfillment]  [Refund]               | > View customer    |\n   975→|                                              |--------------------|\n   976→| TIMELINE                                     | SHIPPING ADDRESS   |\n   977→| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n   978→| |                                            | Springfield, IL    |\n   979→| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n   980→| |                                            | United States      |\n   981→| o  (more events...)                          |--------------------|\n   982→|                                              | BILLING ADDRESS    |\n   983→| ORDER LINES                                  | 123 Main St        |\n   984→| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n   985→| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n   986→| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n   987→|                                              |                    |\n   988→|                    Subtotal     $140.00       |                    |\n   989→|                    Discount     -$12.00       |                    |\n   990→|                    Shipping      $5.00        |                    |\n   991→|                    Tax          $18.36        |                    |\n   992→|                    Total       $151.36        |                    |\n   993→|                                              |                    |\n   994→| PAYMENT DETAILS                              |                    |\n   995→| Provider: Stripe  [Paid]                     |                    |\n   996→| Amount: $151.36                              |                    |\n   997→| Payment ID: pi_xxx...                        |                    |\n   998→+----------------------------------------------+--------------------+\n   999→```\n  1000→\n  1001→### Left Column Sections\n  1002→\n  1003→#### Order Heading\n  1004→- Order number as a large heading\n  1005→- Financial status badge (color mapped: paid=green, default=zinc)\n  1006→- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n  1007→- Placed-at date as muted text\n  1008→\n  1009→#### Action Buttons\n  1010→Shown below the heading:\n  1011→- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n  1012→- \"Refund\" button (ghost variant, shown when order is paid)\n  1013→\n  1014→#### Timeline\n  1015→A vertical timeline with left border line and dot markers:\n  1016→- Each event: title text (bold) and timestamp text (small, muted)\n  1017→- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n  1018→\n  1019→#### Order Lines Table\n  1020→\n  1021→| Column | Content |\n  1022→|--------|---------|\n  1023→| Image | 40x40 product thumbnail |\n  1024→| Product | Title and SKU |\n  1025→| Quantity | Unit count |\n  1026→| Unit Price | Formatted amount |\n  1027→| Total | Line total formatted |\n  1028→\n  1029→#### Order Summary\n  1030→A right-aligned summary block below the lines table:\n  1031→- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n  1032→- Each row is a flex container with label left and amount right\n  1033→\n  1034→#### Payment Details\n  1035→A card showing:\n  1036→- Provider (Stripe/PayPal) as text\n  1037→- Status as Flux badge\n  1038→- Amount\n  1039→- Provider payment ID (linked if applicable)\n  1040→\n  1041→### Right Column Sections\n  1042→\n  1043→#### Customer Card\n  1044→- Heading \"Customer\" (size md)\n  1045→- Separator\n  1046→- Customer name (or \"Guest\")\n  1047→- Customer email (muted text)\n  1048→- \"View customer\" link (shown only if customer exists, links to customer detail page)\n  1049→\n  1050→#### Shipping Address Card\n  1051→- Heading \"Shipping address\" (size md)\n  1052→- Separator\n  1053→- Full address rendered from shipping_address_json: line1, city/state/zip, country\n  1054→\n  1055→#### Billing Address Card\n  1056→Same pattern as shipping address, using billing_address_json.\n  1057→\n  1058→### Fulfillment Modal\n  1059→\n  1060→| Property | Value |\n  1061→|----------|-------|\n  1062→| Modal name | `create-fulfillment` |\n  1063→| Max width | lg |\n  1064→| Heading | \"Create fulfillment\" |\n  1065→\n  1066→**Content:**\n  1067→- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n  1068→- Separator\n  1069→- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n  1070→- Tracking number input field\n  1071→- Tracking URL input field (type: url)\n  1072→- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n  1073→\n  1074→### Refund Modal\n  1075→\n  1076→| Property | Value |\n  1077→|----------|-------|\n  1078→| Modal name | `create-refund` |\n  1079→| Max width | lg |\n  1080→| Heading | \"Refund order\" |\n  1081→\n  1082→**Content:**\n  1083→- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n  1084→- Separator\n  1085→- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n  1086→- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n  1087→- Cancel button (ghost) and \"Create refund\" button (danger)\n  1088→\n  1089→### Flux Component Mapping\n  1090→\n  1091→| UI Element | Flux Component |\n  1092→|-----------|----------------|\n  1093→| Order number heading | `heading` |\n  1094→| Status badges | `badge` |\n  1095→| Action buttons | `button` |\n  1096→| Section headings | `heading` (size md) |\n  1097→| Section dividers | `separator` |\n  1098→| Text content | `text` |\n  1099→| Both modals | `modal` |\n  1100→| Modal form fields | `field`, `input`, `textarea` |\n  1101→| Line checkboxes | `checkbox` |\n  1102→| Modal buttons | `button` |\n  1103→| Icons | `icon` |\n  1104→| Callout messages | `callout` |\n  1105→\n  1106→---\n  1107→\n  1108→## 9. Customers List and Detail\n  1109→\n  1110→### 9.1 Customers List\n  1111→\n  1112→**Route:** `GET /admin/customers`\n  1113→\n  1114→**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n  1115→"}]},"uuid":"65a46ab1-cb5e-4c53-a073-2857e76975b2","timestamp":"2026-02-11T15:09:39.424Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","content":"## 8. Order Detail\n\n**Route:** `GET /admin/orders/{order}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n\n**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `createFulfillment()` | Creates a fulfillment record for selected lines |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |\n\n### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n| Jan 15, 2024 2:30 PM                        | John Doe           |\n|                                              | john@example.com   |\n| [Create fulfillment]  [Refund]               | > View customer    |\n|                                              |--------------------|\n| TIMELINE                                     | SHIPPING ADDRESS   |\n| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n| |                                            | Springfield, IL    |\n| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n| |                                            | United States      |\n| o  (more events...)                          |--------------------|\n|                                              | BILLING ADDRESS    |\n| ORDER LINES                                  | 123 Main St        |\n| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n|                                              |                    |\n|                    Subtotal     $140.00       |                    |\n|                    Discount     -$12.00       |                    |\n|                    Shipping      $5.00        |                    |\n|                    Tax          $18.36        |                    |\n|                    Total       $151.36        |                    |\n|                                              |                    |\n| PAYMENT DETAILS                              |                    |\n| Provider: Stripe  [Paid]                     |                    |\n| Amount: $151.36                              |                    |\n| Payment ID: pi_xxx...                        |                    |\n+----------------------------------------------+--------------------+\n```\n\n### Left Column Sections\n\n#### Order Heading\n- Order number as a large heading\n- Financial status badge (color mapped: paid=green, default=zinc)\n- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n- Placed-at date as muted text\n\n#### Action Buttons\nShown below the heading:\n- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n- \"Refund\" button (ghost variant, shown when order is paid)\n\n#### Timeline\nA vertical timeline with left border line and dot markers:\n- Each event: title text (bold) and timestamp text (small, muted)\n- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n\n#### Order Lines Table\n\n| Column | Content |\n|--------|---------|\n| Image | 40x40 product thumbnail |\n| Product | Title and SKU |\n| Quantity | Unit count |\n| Unit Price | Formatted amount |\n| Total | Line total formatted |\n\n#### Order Summary\nA right-aligned summary block below the lines table:\n- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n- Each row is a flex container with label left and amount right\n\n#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)\n\n### Right Column Sections\n\n#### Customer Card\n- Heading \"Customer\" (size md)\n- Separator\n- Customer name (or \"Guest\")\n- Customer email (muted text)\n- \"View customer\" link (shown only if customer exists, links to customer detail page)\n\n#### Shipping Address Card\n- Heading \"Shipping address\" (size md)\n- Separator\n- Full address rendered from shipping_address_json: line1, city/state/zip, country\n\n#### Billing Address Card\nSame pattern as shipping address, using billing_address_json.\n\n### Fulfillment Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-fulfillment` |\n| Max width | lg |\n| Heading | \"Create fulfillment\" |\n\n**Content:**\n- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n- Separator\n- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n- Tracking number input field\n- Tracking URL input field (type: url)\n- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n\n### Refund Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-refund` |\n| Max width | lg |\n| Heading | \"Refund order\" |\n\n**Content:**\n- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n- Separator\n- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n- Cancel button (ghost) and \"Create refund\" button (danger)\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Order number heading | `heading` |\n| Status badges | `badge` |\n| Action buttons | `button` |\n| Section headings | `heading` (size md) |\n| Section dividers | `separator` |\n| Text content | `text` |\n| Both modals | `modal` |\n| Modal form fields | `field`, `input`, `textarea` |\n| Line checkboxes | `checkbox` |\n| Modal buttons | `button` |\n| Icons | `icon` |\n| Callout messages | `callout` |\n\n---\n\n## 9. Customers List and Detail\n\n### 9.1 Customers List\n\n**Route:** `GET /admin/customers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n","numLines":180,"startLine":936,"totalLines":2403}},"sourceToolAssistantUUID":"4279dc8c-5985-4682-8514-c7a69843ce75"}
{"parentUuid":"65a46ab1-cb5e-4c53-a073-2857e76975b2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Xj81ZWEbE6V752yoevEm2a","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TysxDPw8mz9m128raUQaoo","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":415,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1407,"cache_read_input_tokens":152391,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1407},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VafeToq2bNaKtDoRDk","type":"assistant","uuid":"aa39d082-b042-41ad-8891-94a46ecd9070","timestamp":"2026-02-11T15:09:40.091Z"}
{"parentUuid":"aa39d082-b042-41ad-8891-94a46ecd9070","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01TysxDPw8mz9m128raUQaoo","toolUseID":"toolu_01TysxDPw8mz9m128raUQaoo","timestamp":"2026-02-11T15:09:40.093Z","uuid":"bec7f8a3-8614-478a-b09f-41caa4a13ca2"}
{"parentUuid":"aa39d082-b042-41ad-8891-94a46ecd9070","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TysxDPw8mz9m128raUQaoo","type":"tool_result","content":"   415→### 2.2 Checkout Endpoints\n   416→\n   417→#### Checkout Flow Sequence Diagram\n   418→\n   419→```mermaid\n   420→sequenceDiagram\n   421→    participant C as Customer Browser\n   422→    participant API as Storefront API\n   423→    participant Stripe as Stripe API\n   424→    participant WH as Webhook Handler\n   425→\n   426→    C->>API: POST /checkouts (cart_id, email)\n   427→    API-->>C: 201 Checkout (status: started)\n   428→\n   429→    C->>API: PUT /checkouts/{id}/address\n   430→    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n   431→\n   432→    C->>API: PUT /checkouts/{id}/shipping-method\n   433→    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n   434→\n   435→    opt Apply discount\n   436→        C->>API: POST /checkouts/{id}/apply-discount\n   437→        API-->>C: 200 Checkout (totals recalculated)\n   438→    end\n   439→\n   440→    alt Stripe Checkout Session\n   441→        C->>API: POST /payments/stripe/checkout-session\n   442→        API->>Stripe: Create Checkout Session\n   443→        Stripe-->>API: session_url\n   444→        API-->>C: 201 (session_url)\n   445→        C->>Stripe: Redirect to hosted payment page\n   446→        Stripe-->>C: Redirect to success_url\n   447→        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   448→        WH-->>Stripe: 200 OK\n   449→    else Stripe Elements (embedded)\n   450→        C->>API: POST /payments/stripe/payment-intent\n   451→        API->>Stripe: Create PaymentIntent\n   452→        Stripe-->>API: client_secret\n   453→        API-->>C: 201 (client_secret)\n   454→        C->>Stripe: Confirm payment (client-side)\n   455→        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   456→        WH-->>Stripe: 200 OK\n   457→    else PayPal\n   458→        C->>API: POST /payments/paypal/create-order\n   459→        API-->>C: 201 (approval_url)\n   460→        C->>C: Redirect to PayPal approval\n   461→        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n   462→        WH-->>C: 200 OK\n   463→    end\n   464→\n   465→    C->>API: GET /checkouts/{id} (poll or redirect)\n   466→    API-->>C: 200 Checkout (status: completed)\n   467→```\n   468→\n   469→#### POST /api/storefront/v1/checkouts\n   470→\n   471→Create a checkout from a cart.\n   472→\n   473→- **Authentication:** None or session-based\n   474→- **Rate limit:** `checkout` (10/min per session)\n   475→\n   476→**Request body:**\n   477→\n   478→```json\n   479→{\n   480→  \"cart_id\": 42,\n   481→  \"email\": \"customer@example.com\"\n   482→}\n   483→```\n   484→\n   485→| Field | Type | Required | Validation |\n   486→|-------|------|----------|------------|\n   487→| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n   488→| `email` | string | Yes | Valid email address. |\n   489→\n   490→**Success response:** `201 Created`\n   491→\n   492→```json\n   493→{\n   494→  \"id\": 7,\n   495→  \"store_id\": 1,\n   496→  \"cart_id\": 42,\n   497→  \"customer_id\": null,\n   498→  \"status\": \"started\",\n   499→  \"email\": \"customer@example.com\",\n   500→  \"shipping_address_json\": null,\n   501→  \"billing_address_json\": null,\n   502→  \"shipping_method_id\": null,\n   503→  \"discount_code\": null,\n   504→  \"lines\": [\n   505→    {\n   506→      \"variant_id\": 101,\n   507→      \"product_title\": \"Classic T-Shirt\",\n   508→      \"variant_title\": \"Blue / Medium\",\n   509→      \"sku\": \"TSH-BLU-M\",\n   510→      \"quantity\": 2,\n   511→      \"unit_price_amount\": 2500,\n   512→      \"line_total_amount\": 5000\n   513→    }\n   514→  ],\n   515→  \"totals\": {\n   516→    \"subtotal\": 5000,\n   517→    \"discount\": 0,\n   518→    \"shipping\": 0,\n   519→    \"tax\": 0,\n   520→    \"total\": 5000,\n   521→    \"currency\": \"EUR\"\n   522→  },\n   523→  \"available_shipping_methods\": [],\n   524→  \"expires_at\": \"2026-02-12T10:00:00Z\",\n   525→  \"created_at\": \"2026-02-11T10:00:00Z\"\n   526→}\n   527→```\n   528→\n   529→**Error responses:**\n   530→- `404` - Cart not found\n   531→- `422` - Validation errors (empty cart, invalid email)\n   532→\n   533→---\n   534→\n   535→#### GET /api/storefront/v1/checkouts/{checkoutId}\n   536→\n   537→Retrieve the current checkout state and computed totals.\n   538→\n   539→- **Authentication:** None (checkout ID acts as token)\n   540→- **Rate limit:** `checkout` (10/min per session)\n   541→\n   542→**Success response:** `200 OK`\n   543→\n   544→Returns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n   545→\n   546→**Error responses:**\n   547→- `404` - Checkout not found\n   548→- `410` - Checkout expired\n   549→\n   550→---\n   551→\n   552→#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n   553→\n   554→Set the shipping and/or billing address on a checkout.\n   555→\n   556→- **Authentication:** None\n   557→- **Rate limit:** `checkout` (10/min per session)\n   558→\n   559→**Request body:**\n   560→\n   561→```json\n   562→{\n   563→  \"shipping_address\": {\n   564→    \"first_name\": \"Jane\",\n   565→    \"last_name\": \"Doe\",\n   566→    \"address1\": \"123 Main St\",\n   567→    \"address2\": \"Apt 4B\",\n   568→    \"city\": \"Berlin\",\n   569→    \"province\": \"Berlin\",\n   570→    \"province_code\": \"BE\",\n   571→    \"country\": \"DE\",\n   572→    \"country_code\": \"DE\",\n   573→    \"postal_code\": \"10115\",\n   574→    \"phone\": \"+49301234567\"\n   575→  },\n   576→  \"billing_address\": null,\n   577→  \"use_shipping_as_billing\": true\n   578→}\n   579→```\n   580→\n   581→**Shipping address fields:**\n   582→\n   583→| Field | Type | Required | Validation |\n   584→|-------|------|----------|------------|\n   585→| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n   586→| `shipping_address.first_name` | string | Yes | Max: 255 |\n   587→| `shipping_address.last_name` | string | Yes | Max: 255 |\n   588→| `shipping_address.address1` | string | Yes | Max: 500 |\n   589→| `shipping_address.address2` | string | No | Max: 500 |\n   590→| `shipping_address.city` | string | Yes | Max: 255 |\n   591→| `shipping_address.province` | string | No | Max: 255 |\n   592→| `shipping_address.province_code` | string | No | Max: 10 |\n   593→| `shipping_address.country` | string | Yes | Max: 255 |\n   594→| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n   595→| `shipping_address.postal_code` | string | Yes | Max: 20 |\n   596→| `shipping_address.phone` | string | No | Max: 50 |\n   597→| `billing_address` | object | No | Same fields as shipping_address |\n   598→| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n   599→\n   600→**Success response:** `200 OK`\n   601→\n   602→Returns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n   603→\n   604→```json\n   605→{\n   606→  \"id\": 7,\n   607→  \"status\": \"addressed\",\n   608→  \"shipping_address_json\": {\n   609→    \"first_name\": \"Jane\",\n   610→    \"last_name\": \"Doe\",\n   611→    \"address1\": \"123 Main St\",\n   612→    \"address2\": \"Apt 4B\",\n   613→    \"city\": \"Berlin\",\n   614→    \"province\": \"Berlin\","}]},"uuid":"8bc4ec58-24c3-469c-b6bd-29dc45604f0c","timestamp":"2026-02-11T15:09:40.093Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe API\n    participant WH as Webhook Handler\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    alt Stripe Checkout Session\n        C->>API: POST /payments/stripe/checkout-session\n        API->>Stripe: Create Checkout Session\n        Stripe-->>API: session_url\n        API-->>C: 201 (session_url)\n        C->>Stripe: Redirect to hosted payment page\n        Stripe-->>C: Redirect to success_url\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else Stripe Elements (embedded)\n        C->>API: POST /payments/stripe/payment-intent\n        API->>Stripe: Create PaymentIntent\n        Stripe-->>API: client_secret\n        API-->>C: 201 (client_secret)\n        C->>Stripe: Confirm payment (client-side)\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else PayPal\n        C->>API: POST /payments/paypal/create-order\n        API-->>C: 201 (approval_url)\n        C->>C: Redirect to PayPal approval\n        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n        WH-->>C: 200 OK\n    end\n\n    C->>API: GET /checkouts/{id} (poll or redirect)\n    API-->>C: 200 Checkout (status: completed)\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",","numLines":200,"startLine":415,"totalLines":3383}},"sourceToolAssistantUUID":"aa39d082-b042-41ad-8891-94a46ecd9070"}
{"parentUuid":"8bc4ec58-24c3-469c-b6bd-29dc45604f0c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Xj81ZWEbE6V752yoevEm2a","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012fq3RhcLDwhX6Zw7R5eyjJ","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":615,"limit":350}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1407,"cache_read_input_tokens":152391,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1407},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VafeToq2bNaKtDoRDk","type":"assistant","uuid":"e162e57c-785c-430c-b561-46ee3e80127d","timestamp":"2026-02-11T15:09:40.826Z"}
{"parentUuid":"e162e57c-785c-430c-b561-46ee3e80127d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_012fq3RhcLDwhX6Zw7R5eyjJ","toolUseID":"toolu_012fq3RhcLDwhX6Zw7R5eyjJ","timestamp":"2026-02-11T15:09:40.830Z","uuid":"6a499a0b-7728-4140-a077-e1ce92f77186"}
{"parentUuid":"e162e57c-785c-430c-b561-46ee3e80127d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012fq3RhcLDwhX6Zw7R5eyjJ","type":"tool_result","content":"   615→    \"province_code\": \"BE\",\n   616→    \"country\": \"DE\",\n   617→    \"country_code\": \"DE\",\n   618→    \"postal_code\": \"10115\",\n   619→    \"phone\": \"+49301234567\"\n   620→  },\n   621→  \"billing_address_json\": {\n   622→    \"first_name\": \"Jane\",\n   623→    \"last_name\": \"Doe\",\n   624→    \"address1\": \"123 Main St\",\n   625→    \"address2\": \"Apt 4B\",\n   626→    \"city\": \"Berlin\",\n   627→    \"province\": \"Berlin\",\n   628→    \"province_code\": \"BE\",\n   629→    \"country\": \"DE\",\n   630→    \"country_code\": \"DE\",\n   631→    \"postal_code\": \"10115\",\n   632→    \"phone\": \"+49301234567\"\n   633→  },\n   634→  \"available_shipping_methods\": [\n   635→    {\n   636→      \"id\": 1,\n   637→      \"name\": \"Standard Shipping\",\n   638→      \"type\": \"flat\",\n   639→      \"price_amount\": 500,\n   640→      \"currency\": \"EUR\",\n   641→      \"estimated_days_min\": 3,\n   642→      \"estimated_days_max\": 5\n   643→    },\n   644→    {\n   645→      \"id\": 2,\n   646→      \"name\": \"Express Shipping\",\n   647→      \"type\": \"flat\",\n   648→      \"price_amount\": 1200,\n   649→      \"currency\": \"EUR\",\n   650→      \"estimated_days_min\": 1,\n   651→      \"estimated_days_max\": 2\n   652→    }\n   653→  ],\n   654→  \"totals\": {\n   655→    \"subtotal\": 5000,\n   656→    \"discount\": 0,\n   657→    \"shipping\": 0,\n   658→    \"tax\": 0,\n   659→    \"total\": 5000,\n   660→    \"currency\": \"EUR\"\n   661→  }\n   662→}\n   663→```\n   664→\n   665→**Error responses:**\n   666→- `404` - Checkout not found\n   667→- `410` - Checkout expired\n   668→- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n   669→\n   670→---\n   671→\n   672→#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n   673→\n   674→Select a shipping method for the checkout.\n   675→\n   676→- **Authentication:** None\n   677→- **Rate limit:** `checkout` (10/min per session)\n   678→\n   679→**Request body:**\n   680→\n   681→```json\n   682→{\n   683→  \"shipping_method_id\": 1\n   684→}\n   685→```\n   686→\n   687→| Field | Type | Required | Validation |\n   688→|-------|------|----------|------------|\n   689→| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n   690→\n   691→**Success response:** `200 OK`\n   692→\n   693→Returns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n   694→\n   695→```json\n   696→{\n   697→  \"id\": 7,\n   698→  \"status\": \"shipping_selected\",\n   699→  \"shipping_method_id\": 1,\n   700→  \"totals\": {\n   701→    \"subtotal\": 5000,\n   702→    \"discount\": 0,\n   703→    \"shipping\": 500,\n   704→    \"tax\": 1045,\n   705→    \"total\": 6545,\n   706→    \"currency\": \"EUR\"\n   707→  },\n   708→  \"tax_provider_snapshot_json\": {\n   709→    \"provider\": \"manual\",\n   710→    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n   711→    \"lines\": [\n   712→      {\n   713→        \"variant_id\": 101,\n   714→        \"tax_amount\": 950,\n   715→        \"rate\": 1900,\n   716→        \"jurisdiction\": \"DE\"\n   717→      }\n   718→    ],\n   719→    \"shipping_tax_amount\": 95,\n   720→    \"shipping_tax_rate\": 1900\n   721→  }\n   722→}\n   723→```\n   724→\n   725→**Error responses:**\n   726→- `404` - Checkout not found\n   727→- `410` - Checkout expired\n   728→- `422` - Invalid shipping method (not available for this address/checkout)\n   729→\n   730→---\n   731→\n   732→#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n   733→\n   734→Apply a discount code to the checkout.\n   735→\n   736→- **Authentication:** None\n   737→- **Rate limit:** `checkout` (10/min per session)\n   738→\n   739→**Request body:**\n   740→\n   741→```json\n   742→{\n   743→  \"code\": \"WELCOME10\"\n   744→}\n   745→```\n   746→\n   747→| Field | Type | Required | Validation |\n   748→|-------|------|----------|------------|\n   749→| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n   750→\n   751→**Success response:** `200 OK`\n   752→\n   753→```json\n   754→{\n   755→  \"id\": 7,\n   756→  \"status\": \"shipping_selected\",\n   757→  \"discount_code\": \"WELCOME10\",\n   758→  \"totals\": {\n   759→    \"subtotal\": 5000,\n   760→    \"discount\": 500,\n   761→    \"shipping\": 500,\n   762→    \"tax\": 950,\n   763→    \"total\": 5950,\n   764→    \"currency\": \"EUR\"\n   765→  },\n   766→  \"applied_discounts\": [\n   767→    {\n   768→      \"code\": \"WELCOME10\",\n   769→      \"type\": \"percent\",\n   770→      \"value_amount\": 10,\n   771→      \"applied_amount\": 500,\n   772→      \"description\": \"10% off your first order\"\n   773→    }\n   774→  ]\n   775→}\n   776→```\n   777→\n   778→**Error responses:**\n   779→- `400` - Discount code expired (error code: `discount_expired`)\n   780→- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n   781→- `404` - Checkout not found\n   782→- `410` - Checkout expired\n   783→- `422` - Invalid code (not found, does not apply to these items)\n   784→\n   785→```json\n   786→{\n   787→  \"message\": \"This discount code has expired.\",\n   788→  \"error_code\": \"discount_expired\"\n   789→}\n   790→```\n   791→\n   792→---\n   793→\n   794→#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n   795→\n   796→Remove the applied discount code from the checkout.\n   797→\n   798→- **Authentication:** None\n   799→- **Rate limit:** `checkout` (10/min per session)\n   800→\n   801→**Success response:** `200 OK`\n   802→\n   803→Returns the full checkout object with discount removed and totals recalculated.\n   804→\n   805→**Error responses:**\n   806→- `404` - Checkout not found or no discount applied\n   807→- `410` - Checkout expired\n   808→\n   809→---\n   810→\n   811→### 2.3 Payment Endpoints\n   812→\n   813→#### Payment Flow Sequence Diagram (Stripe)\n   814→\n   815→```mermaid\n   816→sequenceDiagram\n   817→    participant C as Customer Browser\n   818→    participant API as Storefront API\n   819→    participant Stripe as Stripe\n   820→    participant WH as Webhook Receiver\n   821→    participant Order as Order System\n   822→\n   823→    C->>API: POST /payments/stripe/payment-intent\n   824→    API->>Stripe: Create PaymentIntent (amount, currency, metadata)\n   825→    Stripe-->>API: PaymentIntent (client_secret)\n   826→    API-->>C: 201 {client_secret, payment_intent_id, amount, currency}\n   827→\n   828→    C->>Stripe: confirmCardPayment(client_secret)\n   829→    Stripe-->>C: Payment result\n   830→\n   831→    Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   832→    WH->>WH: Verify Stripe-Signature header\n   833→    WH->>WH: Check idempotency (event.id)\n   834→    WH->>Order: Create order, decrement inventory, send confirmation\n   835→    WH-->>Stripe: 200 {received: true}\n   836→```\n   837→\n   838→#### POST /api/storefront/v1/payments/stripe/checkout-session\n   839→\n   840→Create a Stripe-hosted Checkout Session for the checkout. Redirects the user to Stripe's hosted payment page.\n   841→\n   842→- **Authentication:** None\n   843→- **Rate limit:** `checkout` (10/min per session)\n   844→\n   845→**Request body:**\n   846→\n   847→```json\n   848→{\n   849→  \"checkout_id\": 7,\n   850→  \"success_url\": \"https://acme.test/checkout/7/confirmation\",\n   851→  \"cancel_url\": \"https://acme.test/checkout/7\"\n   852→}\n   853→```\n   854→\n   855→| Field | Type | Required | Validation |\n   856→|-------|------|----------|------------|\n   857→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   858→| `success_url` | string | Yes | Valid URL. |\n   859→| `cancel_url` | string | Yes | Valid URL. |\n   860→\n   861→**Success response:** `201 Created`\n   862→\n   863→```json\n   864→{\n   865→  \"session_id\": \"cs_live_abc123def456\",\n   866→  \"session_url\": \"https://checkout.stripe.com/c/pay/cs_live_abc123def456\",\n   867→  \"expires_at\": \"2026-02-11T10:30:00Z\"\n   868→}\n   869→```\n   870→\n   871→**Error responses:**\n   872→- `404` - Checkout not found\n   873→- `409` - Checkout not in a valid state for payment\n   874→- `422` - Validation errors\n   875→- `502` - Stripe API error\n   876→\n   877→---\n   878→\n   879→#### POST /api/storefront/v1/payments/stripe/payment-intent\n   880→\n   881→Create a Stripe PaymentIntent for embedded payment form (Stripe Elements).\n   882→\n   883→- **Authentication:** None\n   884→- **Rate limit:** `checkout` (10/min per session)\n   885→\n   886→**Request body:**\n   887→\n   888→```json\n   889→{\n   890→  \"checkout_id\": 7\n   891→}\n   892→```\n   893→\n   894→| Field | Type | Required | Validation |\n   895→|-------|------|----------|------------|\n   896→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   897→\n   898→**Success response:** `201 Created`\n   899→\n   900→```json\n   901→{\n   902→  \"client_secret\": \"pi_abc123_secret_def456\",\n   903→  \"payment_intent_id\": \"pi_abc123\",\n   904→  \"amount\": 6545,\n   905→  \"currency\": \"eur\"\n   906→}\n   907→```\n   908→\n   909→**Error responses:**\n   910→- `404` - Checkout not found\n   911→- `409` - Checkout not in a valid state for payment\n   912→- `502` - Stripe API error\n   913→\n   914→---\n   915→\n   916→#### POST /api/storefront/v1/payments/paypal/create-order\n   917→\n   918→Create a PayPal order for the checkout.\n   919→\n   920→- **Authentication:** None\n   921→- **Rate limit:** `checkout` (10/min per session)\n   922→\n   923→**Request body:**\n   924→\n   925→```json\n   926→{\n   927→  \"checkout_id\": 7,\n   928→  \"return_url\": \"https://acme.test/checkout/7/confirmation\",\n   929→  \"cancel_url\": \"https://acme.test/checkout/7\"\n   930→}\n   931→```\n   932→\n   933→| Field | Type | Required | Validation |\n   934→|-------|------|----------|------------|\n   935→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   936→| `return_url` | string | Yes | Valid URL. |\n   937→| `cancel_url` | string | Yes | Valid URL. |\n   938→\n   939→**Success response:** `201 Created`\n   940→\n   941→```json\n   942→{\n   943→  \"paypal_order_id\": \"5O190127TN364715T\",\n   944→  \"approval_url\": \"https://www.paypal.com/checkoutnow?token=5O190127TN364715T\",\n   945→  \"status\": \"CREATED\"\n   946→}\n   947→```\n   948→\n   949→**Error responses:**\n   950→- `404` - Checkout not found\n   951→- `409` - Checkout not in a valid state for payment\n   952→- `422` - Validation errors\n   953→- `502` - PayPal API error\n   954→\n   955→---\n   956→\n   957→### 2.4 Order Endpoints\n   958→\n   959→#### GET /api/storefront/v1/orders/{orderNumber}\n   960→\n   961→Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n   962→\n   963→- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n   964→- **Rate limit:** `api.storefront` (120/min per IP)"}]},"uuid":"40d2d205-da0a-46c1-af7c-7512e08d723d","timestamp":"2026-02-11T15:09:40.830Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoints\n\n#### Payment Flow Sequence Diagram (Stripe)\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe\n    participant WH as Webhook Receiver\n    participant Order as Order System\n\n    C->>API: POST /payments/stripe/payment-intent\n    API->>Stripe: Create PaymentIntent (amount, currency, metadata)\n    Stripe-->>API: PaymentIntent (client_secret)\n    API-->>C: 201 {client_secret, payment_intent_id, amount, currency}\n\n    C->>Stripe: confirmCardPayment(client_secret)\n    Stripe-->>C: Payment result\n\n    Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n    WH->>WH: Verify Stripe-Signature header\n    WH->>WH: Check idempotency (event.id)\n    WH->>Order: Create order, decrement inventory, send confirmation\n    WH-->>Stripe: 200 {received: true}\n```\n\n#### POST /api/storefront/v1/payments/stripe/checkout-session\n\nCreate a Stripe-hosted Checkout Session for the checkout. Redirects the user to Stripe's hosted payment page.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7,\n  \"success_url\": \"https://acme.test/checkout/7/confirmation\",\n  \"cancel_url\": \"https://acme.test/checkout/7\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n| `success_url` | string | Yes | Valid URL. |\n| `cancel_url` | string | Yes | Valid URL. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"session_id\": \"cs_live_abc123def456\",\n  \"session_url\": \"https://checkout.stripe.com/c/pay/cs_live_abc123def456\",\n  \"expires_at\": \"2026-02-11T10:30:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `422` - Validation errors\n- `502` - Stripe API error\n\n---\n\n#### POST /api/storefront/v1/payments/stripe/payment-intent\n\nCreate a Stripe PaymentIntent for embedded payment form (Stripe Elements).\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"client_secret\": \"pi_abc123_secret_def456\",\n  \"payment_intent_id\": \"pi_abc123\",\n  \"amount\": 6545,\n  \"currency\": \"eur\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `502` - Stripe API error\n\n---\n\n#### POST /api/storefront/v1/payments/paypal/create-order\n\nCreate a PayPal order for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7,\n  \"return_url\": \"https://acme.test/checkout/7/confirmation\",\n  \"cancel_url\": \"https://acme.test/checkout/7\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n| `return_url` | string | Yes | Valid URL. |\n| `cancel_url` | string | Yes | Valid URL. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"paypal_order_id\": \"5O190127TN364715T\",\n  \"approval_url\": \"https://www.paypal.com/checkoutnow?token=5O190127TN364715T\",\n  \"status\": \"CREATED\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `422` - Validation errors\n- `502` - PayPal API error\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)","numLines":350,"startLine":615,"totalLines":3383}},"sourceToolAssistantUUID":"e162e57c-785c-430c-b561-46ee3e80127d"}
{"parentUuid":"40d2d205-da0a-46c1-af7c-7512e08d723d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01AGXDuHk4z9vjzVucaJMeLB","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a comprehensive understanding of all the sections that need to change. Let me plan the edits carefully.\n\nThe changes I need to make across specs:\n\n### 1. 01-DATABASE-SCHEMA.md\n- Add `payment_method` column to `checkouts` table\n- Add `payment_method` column to `orders` table\n- Update `payments.provider` CHECK constraint to include `mock_card`, `mock_paypal`, `mock_bank_transfer` (or just keep the mock provider as single \"mock\" and the payment_method on the order/checkout differentiates)\n\nActually, let me think about this more carefully. \n\nThe current `payments.provider` has CHECK IN ('stripe', 'paypal'). Since we're replacing with a mock, I should think about what makes sense. The user wants a mock PSP where `payment_method` represents the customer-facing choice (Credit Card, PayPal, Bank Transfer) and `provider` is the backend system processing it. Since it's all mock, I could either set `provider` to just `mock` with the payment_method differentiating the types, or make it more descriptive like `mock_credit_card`, `mock_paypal`, `mock_bank_transfer`.\n\nThe cleanest approach is adding a `payment_method` TEXT column to checkouts and orders with those three options, then simplifying `payments.provider` to just `mock` since everything goes through the MockPaymentProvider. The payment_method on the order determines the OMS workflow. Orders already has financial_status to support pending states, so I don't need additional columns beyond payment_method—though I could add payment_due_date for bank transfer auto-cancellation logic, but that can be computed from placed_at instead.\n\nNow I'm mapping out which documentation files need updates: the schema file for the new column and provider constraint, the API routes to replace Stripe/PayPal endpoints with a mock payment endpoint and add admin payment confirmation, the admin UI for a confirm payment button and fulfillment guards, the storefront UI to replace the payment step with method selection and onsite forms, the business logic to replace payment processing and update OMS workflows with payment-method-specific handling including auto-fulfillment for digital and auto-cancel for bank transfer, and the seeders file.\n\nThis is a significant refactor across multiple files, so I should execute these edits systematically. I also need to make sure I'm capturing that fulfillment status transitions happen via admin UI buttons, not automatically—the user was clear that events like shipped are triggered by admin actions.\n\nLet me organize this into phases where files that don't depend on each other can be edited in parallel, starting with the core schema and business logic changes, then moving to the API and UI layers, and finally updating the test and implementation documentation.\n\nNow I'm mapping out the specific changes needed for each file. The database schema needs payment method columns added to checkouts and orders tables with validation constraints, the payments table provider check needs to be updated to only accept mock, and I can keep the existing provider_payment_id column for generating fake reference IDs. For the API routes, I need to replace the Stripe-specific implementation with mock payment handling.\n\nFor the admin UI, I'm adding a \"Confirm Payment\" button that appears only for bank transfers awaiting payment, along with a warning callout and logic to disable fulfillment actions until payment is confirmed.\n\nNow I'm updating the storefront checkout to let customers choose between credit card, PayPal, or bank transfer, with each method showing its own input fields or confirmation flow.\n\nFor the business logic, I'm replacing the payment processing section with a mock provider that handles all three payment methods using magic card numbers and no external APIs or webhooks.\n\nThe checkout flow differs based on payment method—credit card and PayPal process instantly, while bank transfer creates an order with pending financial status and reserves inventory without committing it.\n\nIn the order management system, I'm adding a fulfillment guard that prevents creating fulfillments unless the order is paid or partially refunded, plus a \"Confirm Payment\" action for bank transfers, an auto-cancel job for unpaid orders, and auto-fulfillment for digital-only orders.\n\nFor the seeders, I'm adding payment_method to all existing orders and updating some to use bank transfer instead of just credit card.\n\nThe E2E tests need updates for payment method selection, individual tests for each method, a decline scenario with a magic card number, and bank transfer confirmation flows in the admin.\n\nI'm going to start working through these changes directly rather than creating separate tracking tasks. I need to prioritize the core files first since everything else depends on them, then work through the dependent specs in order. Looking at the fulfillment status transitions, the business logic already describes them as merchant actions, but the admin UI spec is missing explicit buttons for marking orders as shipped and delivered—I need to add those to make it clear these are manual admin triggers, not automatic transitions. Let me start making those edits now.","signature":"EuJMCkYICxgCKkD2OmY8sktfctkBeSkp/X5aI1gJ0jWTHr2+4thNM0riFDVEKO2w3hnvAYUXcNk6A6HNWPZrRmIdFBPX87HlxQXQEgzxhrSo255zaCQdEyEaDG2OlkzFawaOJkIMXyIwpFnzTERdbK+czgTfDMv5JuYj4UeebHGXfqVInl1j9wGUET0fU5EEzqZaO1iEDG27KslLDnX/i+vhY3rX/Lfx5zfvelRDbs4X1nmK+yyiz3UYI7C6b+mgcjVSN71myFppf9NJsLpDf8dmOUa5/X5+J4jOaOQ48C8J8mUZz4oEO/51/N67+fTAb9MRzJvQTydjlBJw0fgkNaJgZQC22WIYhhUbVamsmHOUt2TV9ztuE17wOF+0bgFKCyQpggy6T9gJT3szdMyNKs4VDZYTC9BU9F9GX/3l0whySF7oP3koe/bX23N1aD5/jfslpukXULztCDdP3+2zlcF4LSJn6bHIPhmvItmINVVOC9w5Ojw6lsLHiP81+3YVc8Ik39UeGtVLInNrnk90F3HNElCqQgKKh9G+0Vxtb0xB1PUEKm90ySZ8rQ+r0iGIlXV/ER8+8vIfLshGA0knsajg605Qp+cQN4lIi2bX6WaYghTBG5q9EeR6pA+qCL8cVUZutVodtjAJKsf3LCGM5e6eKw7tLUZZrsydPYC4ZdV+E2RTvebUtFcjVgSsXq7KvvB8TGcomaL+gVOtl7h1AqFjridWvl/itVJK9ZqTzcTXmsQCGZdVhHcPnb3IbnlHtfXdpoElZqmZu0V6y5/QawKrsU/xgBKcRweQtPzSavE/V9Wf1wQFYYyjLf3Qv+llXGBgyO4a5bvb7WNZl/NBiwwhHl3TR66cUaNDviBQoQLx0tOZeAkkSeI/AKsMu+RTQYmLVsWZkj/IhfBNzAEzG9oIsPCr0KyamQYe2j2JVH+27ySGee+5EyQ3ZmiOwOAaN1Sdl4HzHZFyTml9i9MI5kqjy89f/NbgS+305oNzPH8EG8KjnPfa3rMU3WGiD8tncGC9W0Od2xvLKQ5s0x4DWpA9b/Tbcs8WtOeAod0t5rdHOJUHqOOBbgukvfkZvarLPW5zsCwFbg37OY9lZKT7DcG4Wxbjqp128ZTCwuARN1MD3Fb3xQlKiZNryMzldy/65ROW1TJRh/Q3UBt/jifKIP3XpvMMDZBPpnPvYMS3Dqjy6DerV9vAp9TyhktQLrm6m+fSaaWKJ5ZW8is5XmDq7PDkzaBO+y1bNuAy1WHaiQHeglzz1h7egXHie6dcpnWH+OWwHMpctiGxdBrr56+wFYVJnW9fqH2zSzjdLdR+bJX1YB4uaUUowdBQ6GlaT1SWi7D7TsY3TMyp9dHqq50e02jyX/AoFX+vHOffhAMOBYLQwBHWGxw0adUpMPuOQrGo19ZAOMHxwByVIz1e81O4SwWpTfsPUrvQlYjAcoUuqz7jm3s42Ce01xKibMJYreo4b+5PRA6EaHOOPVP+si41zD1A2wxauqIuW2bIxU71HiyJNHLsPvBrUSauH7Wx69GeypQBz7dhChPOw4ZAY357bZdEAfLrjg5/WK3h/aTKHtdNVSY6YtJgb+OQs3YVsWgWM1/xV/pRpstaMu4b6hcPL1N3thdTAtpd5yLAOc9V6DRv3kIG090nHZRleLgY7Pgl6H5DSi9k/6DhO9X4J3LHjCIYUDP37Tw8W7lPLsijKwBmSKtb0ZqnlPWCuCvm5uoX+rPC3xjRpteLTEg1kewtLu9wx6/pj823QE+1LolZxW8dQxBdGpT04AewBUjg2reNJkVVHaI79rTkim+DuH0rnqFk247SKVEVht2+02uK6j9CAk8Xet/Pf+prnAOWtV1TKd2NKlymvDhg+0HZrcDHR6gbFDcXq2FVjAyIPpN1kEQ6FvRKBieJb4tMNnGA8iTy4op0tXaxUSwXcOPghoWZT1TNScfGS4qqWKf09oVdTKMdL22DPhWlKdkOgNsdrkNPGK6oT0tN0EeRMiv36sYUylK4CDqbEmxhXsNBJpKmiDQr0rD9rF6bt9UfgRpu4de3S4eKWyN19vnr9JUs7sKXtjZaFmG0WcMFHq1QNHEFqPvOvRZgM6O/i09VxSfcItg1OlxjImS3RBtFcs668v0QjxQBMvbY8STCeSEi8ckv60mgBpG/3FzY13a5Dtfto8/ZxEUCUlDbnyPrsvkdPAkPaNfSHtdAo8hXNFq60F4yqak/YYg3KW4Rq/TN+sRCCeC2cw3OR9tgap2DgJPpC+OJ0nISpuewjnkQadrDHi6JeSBQV+G3ayNt0XwqH6IFWw/6mgHpYHC9NzI8OX8HFjfq+Q0HgftuWi52kUELiJJF2aNufxBtyHoubC5PFdQ8a94x/XGGfVc8El2TdOz+w1SuDVgcprHpCfOfx/yvA987ixNkpbDpp1xpMYMKkB0FkNWbdh0aSkYcINbT93zcMqdCc6LjmULAzj+okpqt6xzZShFrufiSA+LW0crpyAt7V8O1dNaplPSMevr4W388z8lRQ8MxYK7gOpbLTg3Qr3HdRz28Y8FEhF0QTJXSgaxUM3fvrIRZNzG8H5FBTCg22uA2wsz0qhdVE5uwAlpQzIzcCBe6yxqxjTTiha26Q5uRQUhJHFKW6UpkxTdzUeXUpg5wxhJxzKmnzNOrNCkOTU++WaC5FEbafv2vxGOwiPip9buGrMUG8AFI/u/MavPBSMNF32CqeB76Jby0QD6BUMr7yPA9eZjxwYpFniTl9zD5HSlHUK+Ppq80F+zNAX/ZkPwqCuECtFBzCL9wJlJC7tPjwqd1RMim/cl6zb3PQGAToZ4wtT5YVZ+rQCmqN47bPgp3jjoVvKEyAbhCAD1Y1CrsKOiDygPiIrF7seOySlEX435BJWjcP1EhIYGSXfXmP1WR+8qfsLCY/hwYXnurwsmNJ3Q5IVH7Pux7f/2EkgicU5w3+fEtYjoDs/eL3z8uoZmXRlBp0TFtC8POnXbsD9OhAAgjp7hkp3Mgj3VH+WoSV3kmixxaTvIBi2nRP9ua82ZM2bhpxq8bd/L0n9iTJ7QB1Ob/zmjYYwgGiCApCk9iQiu4DcBp0gdoU/UzciTRhDZY9X78uJjs4M2I+yOlcGVBTR8NBkvy9us58DKLTLmoNAZoZax+9jV3SU56kGZONXqCjpKJ2XH/pxT4dD52oU3LT32HgItBkttS2lX8etVWkZzhHPDMhYfT1qDc73ZXjMakC00RMrbrNGCu6+KMlL+b1vzh5NXsQarbq2bmSA7c+WMyf9HlMW/Hn/1MQFQ8HTLobBD46KogCAEU/WvI8M5SBSoxicmIoEiLforB6r9l1kgqu8HcoZcEZXeP8GtJ585rb67B1fB1ApAoUtlHXVPYCE6s/CTA41/n76jwu4VkaKr1np6+k+IYJXvP3/v4xlB3oxWwz4Hr3wBqwbDCFMXF3GYmgKz8UI7lwhxNDS+igMasHIGEJA8NZk77GOzvMfwoG/oXRzE7FwYVaQUyRwsS3hhxedNvDURUVSFsnJMXvgmWqqcsT/IvVPX/m0s1iM+y+JFmjr2T6OR9sbgU8jB2N0yyO9SRAfc54VBZ4uLduX1BmhhZYKZaXEG+n+jM7nP3eOR2odoDSe8FRFjRjP6lvWmr61i2CSuglAaufMpUiIevjBnzDyLVndPxVj0A9EfBDx+lTooQguEZmkQUN1/+unhkpSfYdf6oPDWCc4y9ciiU8Yo5guewb9uWfdz4rsMjP5L9kG5dOZxFk0OVkTdWFnijwPtvQdcd8E72yBFY1I78DEG6GGmuUnOuXjGyPvq2WK/YjM0ntcmIvqG39OF7KDyKCAp3uzsc46Fn+te5hvAkN2w+aWjFVHk1ycAMlbF/kHNAbGDkYj+0Pl6uEX6ADIvATBoLO2Vvgs1ROyZroZt0ycebE67GGPYekiVij6Vjg9XXfSFyb3rLUIi3YpcE7WbQJdKd4kjNHC0By1BuasScf5UFzZGEcJC0oNF7n19N9PnErniktCrwQagnRZnOQdMdRxKvFUcvnarJSymIIfbrZm/gelUNH28w+GrCOu+xZa4AnJsStbSckOSOnGsJHbaCBGXp5kYUSzd5gCFLvpcarX2+hmHYNFtr4eiSX8ki2kzN7GabK673BF4kYR6zEVMVh9f4+ZImv1n/xP/FVuVel178eynhOsWMQK+6VXi0ZGKxovqt/nNzsPoUipqlMdvbvSki5XlaJxfTJUFlBueocwz5dkaflAdS6A41Q8SKMY0xSFVPrXF1WqmqwgGlMjP5/t3czNUPTW4dunu7Yq8o4imIDAF9PG/9yX+7YWgMm5GGV+JqmYWj5l+tE+57LgkQrIBrMogFQWTT9dm7GoR8iItXCAhdqVe4NqU7EpQRby1NihDESqZXGwXtHBw3yftiZMEA8YLXXiQNixJ/UiP6FkDWnxirvEd+I62eodFyzwQtP4jC2lux1zft4ZKGZqxlgvhA6znj9Fh/hrywf2wljkcaJMFczknszirI7YgO3bnU5axSoOJ/1vSsetouCJihNWT/XSZS1Re4MopRFQHsjudZM3xr9H1sg7bNbXbJEefm7pt6qogXwyjz6DaoHcvnxpmGDCWbjNruMlndUW+0IcAgBU9kELSmYwlrrhkaRCSYtoZJebXMVRvx3I2oqfwRu8Li7NGEifmiAChuyQNGuL9ZZeDmzbXYCpemllHlB1Pr7M454neGd7C94ADxC3Q7efbGH9ixTnQ3tIbysU8oIoIPk7XFN6kP341gwoa0pa1znp0oHyI59uVMgwA6MkvczwELNHA8DdOwAOm8Rb2zY0oftoDysLhMVguJHs2axDhUeOSYXAKxCFIaBN2ecPb1qcQIX5ZqObLC+tkMGxKoITUkcfdlt7iodwA9JOB59zwieQFRTP8TzO3RiyCC1W+bn+cJf05FxVZxpzJMt7EpemDA5LkHYU7kSedqCvPlnuruHzPiw7PX8S6toXMM/SrpLJ8OVIEJJCpIrSENWcKREaWc6QatmQs8K6SaV/BvTCEd4PBd/voMXTq2SwAftI4O+BkajXNMKq8h7i/GzeAw8IpgWInKW4JRnYkbnf1E2rkEjjZvesQfjQoCBAWMJplJpRzyCaf1iUDZGmmWHxvSRMHHE80Uav6lrON3rVcSxDVJ2pUMyXbFrYoAwMQGssM4UAhaJf06QZnFznKA8LM4JKEiaGXnYhzLq6sOcPH4gIpWbCIBswokUixPvJTOEhhGvHOxbGzu78bZJinttWTqw8ThZAYZGb11LL1J2aW/RLw4hhPfQZJCvf7Dt7+Laxvm/qrWzIi/TrcZoxD/kO1T820a/O/MqzktcbMGuw16J1r+riq+/yDjMeahA2CIZGQ2/V/pwZxOWbbg1PaTwijATjVSxJkaBgZnuB039uaQd7zx4DH8v+8D5basnI/MzvSxupZiE01RHcyTS73tWXIKzgU3qa6Rs6UWDkVsRpGAH9KQKLdpbzCDWWXdx8ENOE731u4Shsx9SyuDrRNYBCteh5CS9YW5qaUKdzxAa2FRpl3giIeym8g+A5uRXtjHk0mmheAWlCYe6ag02uCpr6DE80IxTY2jSUgohx9LM1N9MpcHPMU2qyIvTFhbshRB6sUeHIkb9/dPuzFNrc+NPrbBzEZ/4Wga9E/7Ghe/553FfSvDmOCLKcaQbL/34DxPdK4EWy1CilFKQCOmXpCoChX97cAtOUKFafjd8HI9DEJX/uzRnaETVIzsb416uLioOxpd3ON/epBCqU0OBlzTaTC9/wHaUqlUoAizdYxmMJY8BhkpcMAKrbNLrtF53lgFW3GgOjF+L7nQ85E3MYV8YX9WqVLZl+2VyzdnNaddF7PP/LnE6Oi21hUeIENS0CQ2fhPDNMsPdtT1O97V2j1ODh/rvcw1JNCsi8tIG/j/wdjfXOFFjAQtkbxBcxbg14joAriPwMoymC/C9fRPBgu3xPBq/yS/zrTNPQBfzuaC/uW1w12qnabDiY4bnqwLouChrJRPlV2nKBb3l1e97x/QMtUpgI9jSFqqTDYqm/ch05OM5btIyf/Sy+cK/89qD4vBO48AhBK+iR/lV5izUNK7r0RQLRovdvFkccvlKM5bkcDwrKtcQU8qRhj24D7wvtCZrpIvG2BwZQvTzew4uuON/GF6rvCou+vy+0WBPSt0yDT1e2mDJ1PoJTOTEQ+hol0rCj6sOBWJm/k7CPmUaUrqolSeDuvAhUQVdxD03TT99kzRFxc8LT/vgAWfCav8owSn8EQd9pAepZ2uiekFBE7ZEOwLUhHwvqLOvPevsf2wcezgSVTiyZZsLuLeLLPm3PUeSY5kaas4OZum3O729EIFQ0YN4uI3Fssn579vcwYnq8BGfwf/h552HI0WfVQdj9rnydbDXvV+dyFGkrR3uGFK+8Xl4mCcFFbztG7b1Tq5mCBoyWjC7r/c2UkaOBRDv4KN7qSOgmzYc5q3S+weVqLnp74oxaEHTAme4JXMiEA5HHdJGjVorIQPdTEfHxWgnaY+bbxOM87ASNAHPpwnfCZNHC2Gq2c1NFfGjIT/fqeRY6BEaR+UGznUW8ixkd6sH/i5uWz9PJs97VmMON7F0HUpir/3zWE/yC/aR4uU6KKFN+PvCAo8e2VVFA3ITp2ez+FI6C/E8fnfahg6XWeg8PI5XLcJPblD9ZmCjcQD3vUsR1bU1+W/tYhTRuekJ/t2E7gAOCFJ3D63YCtoFqbtESqKv3Qhh8RplmwWrADeXn9WXu3g4D49OQ0mHW3BO3YVYp1YIUlIWqvjyczHGqbxdr/5UPUAoDH0gy4DCStpwlRcdzQJFDnPmYuhSRwV5peuZuAh6q2Sw9D2FigA5UDPeErrtziJeP7ouPsPqQJd06Xa0VgodJapzk8r6wnPrhNAjHpzW/u/MtUiSpXHVpppjvqI8z+6hD/tXt2g7H9nUZFeeEzl+SGVSEw1/fJtYMhZKG6/MPiHiyOg9wmws/RuvUtgB9EtfMdnzLMqYUSZDkl3+3/j5SzTQCd+JbK6BjPzPOMdzOI/QNuuM8UctnfY6xYp5o33CxOHhDc0Qc/PI5jMvt7G6WU2aYZ4O5VOR9px/0XlixAAxWQcTzR8Ja5cmuMgZQZ8vLo+s0N/5+nTvTUnyrSK6mKZzVTq2H+2gheqWzLEb15w4l6AP9IfFoicVufLGy/cgwqs7URQ/Fn3NpnsI7MSoo3vu/UA845xxvObQPODN7GNnkQUNucQHihZ3jwoRcCyNW6Vog7gm4/3xeQw4xTANo+WegnrAxw42IkKYn5l0sw6ZB24TsfzkejQnvbP5EoMmztVz5XfENQKAWM7GydINZqK/IccMmXAb5em9/4kFBV+dn+KWOLOaANrz5dfrCcD7VrlcytHzoMyG3d1G11u6hX1wCFO2R+GVYAWumY7KToee41lHo5MgEogD/pxmBN6WtGSS5sIdhuhYnDWye9QzlkWs4A17H8vJqlTHTNJi++5ehufPkPAOYjspyz7Gtv/5cwQ5cr22btqd55nl0ILaDbTNdri1sXoc+aiKYx/l2TMjoiS6ZGqTGoD1PjxLKHQPAJ/AE3/dMTLpaXslr0hyaY4YRK6JOOefWshxSQ15+6fXPO2G7PNhgEe/PZ5ZYkIePxnC3PIG77mR1vP76hFo0xAqPg2Gmwk/vh+ORUcAwIEvGdnew53jye5pGMzuU5UDMiOhAqE2DbBtoTvX/dlaHLSGR1ASKZD8403TuccD8gTgnQooYcqS3wt56C/1HsLQ6woedcAKOVNRT/c4weMQaCUWlEKT3CzU2V+VIniMfeg5ln/Kqjs+dMU9HOVi9xaqb4azF925VdEuIs3JzoFErC37rwqWoCxrmC6kk251WLZmPxqAjPKNiDtJoHTaee+BuOcElGlJAa7o57mmKgY2aQGdZFA0HhA/0djgFb1LAv0zyTk6YHuXOlWaJotQaGasEolzUVjGh6rtoWtl7K6rt4uDB/RBY4hPC4PRN92BZd1U9KCbHjQ9oFMPy9MRXRtPyKV8j6CooY+B/XhrQaSrnQokdvJfR4fnPaD+VoDkhQUDkQIKtGq1AayqRe7+H6PzoY34/8NHdGwau21urDp2WYwRRMquGiTn0f6WdZLY4lcjMqsAnE5ISZZF8RxwZUaQl9+9y3XZ/6M2V5C2my5QGDF5TF21mI9LP1HUN/LVr+fJZprtp/ho0K0+fs4pFGyyDb8s4t9W6BnDINclPCeH66aGzynEWe77e+KscCaWjfHJNqz+tA6LIsgvHaGZ8LtQS8h2yL5bItAoR9xJgE5n+Ox5B/edbPORd/FNCTGV3/7V0/81RaXnrNy140SPmhXmMnoR8b0DWxjPAOiVLOgCuevg3tVxVLVcp6rB6u1vLUmPiIBuP8wGu0glu2LfJwRM6tJSphJFwe3hPVYIkmNmHFg063AjhUB7MM2LihqR6/iNsAFYMpSJ3VtMR+egy1PfyRAHcKqQIIDDzTwsOeqb7Ihn8NW6Q7OWaM6pPwlNclflWxZoOe17emVxEDLVr7Z2ki+P/9UWHhhhuqfzU57F9jOSQRjlcFT82P+KpuzsYtFPSrraI1HXQx2WTXbGMRQWmWNoySF5k5TxXsVcqo/Jw1bC0OdTfcm8PTW1Rl31jjEQj7/nI+yuYlQSoXmWIg4lW2wlLbfoGQ5eRYg4998DGWraovyiNe2nWgBfS3UNartT9zbFGeR6wfmDxz6FdP/Z9QycYyuPZaL4usWvWtHXGN/owBSR98nxt57jQWV10aDH0z6Ri3pqzLPLYTA7jSI6ZscGo11F/dEobATr/j94VDxhsK8p1X5SvHBkp0uQUYQFSsxI0cWiKTb7MYHQ4MmqM3Xo3HajDGZgfyiZeamHns4/l4LH1rjotNCAbm3RUkq9lmJ1CJTMuMfx0VsIWzrZdCWK+j7E3tRfytUU7OVyT+yt1lmqTZdOHCN5rRhhRRXE7Y8Al8i5+gk3S7hfCu708K3CXbTTUt/Wl9BgN4Qdt0O4Dt6rFXvXnr/rvoTg8wXSkBQ8Vc24Z2TicncROZ1TubuA7KzHqHq3NgZyrlt2FbII3kBZt/wUydSnkwd6+eDkQbO/8EV754apfdj4gONcdF7IDcydEkSb2+DU5WlX8K6ESWxBICmO6LwmpiIP+Xw8JSq9TDPuYz+SQ3tM+ht+p4GwIm8JpkbrZNfgOO1PIRTgQm4fnvn2pTewKMAbT6YDn1MSzbODtxr3QlRm1f/zYpkHH59RJfbe8iQ8neIJFGFT74TiDo3JCZwaOO07koiWY0mk5zVmQi3SUBjezA8BpY0eViOBS4jqdGMOv/5OIT/fGwHvE+GsNcnDxA2kY+UKV96ZhvrFyIzYLYbExMxUy62wrntRKPdLabZYpeBYWlXTUqkH9ZcPZdT6GgUabgKNiUaV9Dm4/7WN3GT87tYv0UxwqbM1I2o1rcUQj3WaIPRxg1oBVJw280UPn6ca6pFj4+fsA8haBMHUZzUen5JMd1p6bYFtvo8RUqe+khG+hEWispEjpWOYEAe/deOPv38Uz1Bw86dCfO9Jy2Yr9xvXOrOMAXXOMEabUpXE1s8NDp0rRFoZ1k9A/gFO5kKjL2as2brbMG+l09dtvxvYlthHpI36Z4uxE3/0WqKB5+TpWYUaPuVGFfOpROyhyYW7zjDkuJe9dnqRo7qXd/jHjZTTHtpk4SLU/5M9H84f5oE748jfGj5o0gS06wtBv6kEXXsU7YEbP++zvSwzQeizklEhfYAq2CT+/OUizNpJ1c/1cjqAhbyaWksgExmKVnj02n5wFdJzVS/Nr9Ov81CBp7W8SNF9yjiIc85zWRSjPZW5jFWlnLJQNRPysJCeo7Gb02nGPFRKf0qsQe+xa4e21ppZoIfg4JGlbxabq5PwPQJMiKYvbrMewtYoZkwdPMJHLSpK+ixcXm/xu/wHcKP9KDhobG2LXW0ZwFdOvEXJr17klVupGERkdPYxTONnN+v4H+fbqA6/AzzDOB3L5PQbwEWrAshLURmm/PNeY2QgY1cgs0aOFSJUCcZDYGRf/r+64f4+fmGDet26R4XPgva2WyIAquoLd83pOs9u2/9nVY/x7LGiOjiOoPC3yB9da0r8ascu0uKZ1tndZm7CFGoKRRzYjYk+3yR9G6HwvECGl38eddvTgxyrVbQybpLw9/skNMXWdf1Kg0PiDd7V0xH0GcQDgmLHQvYr+LaIMxY2bS+ytx/mqUI/RZIGay3VSKDO+3OnDizOX7zV22l1Ao9Dt1Nvmub0gXwH9ScPwEnqwGxqVMDaUX3W5U4tB5PzjMBS3jvR5mYnRPPbVZklr34/Lq4pPHUFlzys5aH0RItQ29Hz9dDckYHmL92ZMfZSrGh//SVEODfzVOyf7herO82mtM63l84n0UHHswecvS4Rjn3cYfzki/1p6MhBIoF3UEiPVN+SVsSrij+0MUkaJOL3YjU/cTReSBy76AG86ImCiVlGJS7KUk2PYm3V7c6DnGq4+cvN8qqCzB7BasoF3gRY2Wi6YPElngw1wCTF+gNjrx0nSoNmDpApOkjz8M3PWU7O46NO7uy/8f9oG4G2mMu/LCMhH4CcX7GCpgZIjui/8pdjLFUB9z67VXbpqQ/V590bu+HLuf0VAtdZdL39yoM7pTeBN2Y2C7AsPQ7gZdhbd8DbbMn6Y2TQRpCYvy6YIj0zeTcag91bDMLgFvaAqfMF868PREV/9rL1JWnKNEaGa5BOZJEBTbMiwzgd7eJNC1BhwNwle8W7H1+I/eZpmj088hQfhn5qJtbkp5I0+W2Sl+U28cAx57UFxqPXqk3C4qaZWC0mjTuoVHUjUcavU3K/RzXJup+rh91G6n+M0836qPfKjwkAqO+J9vcHZ1KbfwP5l+fCuxEapOsg9/qXlT6mqG7alVmcrtsZwhkWZMxy3k15z1u5H2dXfjilZvAQUKXNCgSjQjZ81v0c5jDpIgKK2O7R9HdmaKezx1b9dfF/qi0uzpa2Ia/F/4IKCnsiWG7Kedsr3P8bfeUNx61v2r1mApd2DNqxhKjEhhMVCj5p6OHKMnERI5gWccyVZa1J7oc5JrtxAAFRw+u/cwBsgD8MyWuoNUfUQJJlQhlXsD0+LC7Q8H8ILcjTffQNXEeTyp8CCzFPNlVChtd2NU9qv5GBctcgJDdOZ2/YmWUzmphpTOYv+99lRVcexoVPZGrgY5Ow9IXbz8kQu4kgy/kJDXdnZ/rB3Ey8w4ns8HkhaSDGyqRopZWi6gamYNIJJ7eNfon4a1TVYwi83bgEuucznIjnvoCAM066DXa2W1PGzbFNGyh8vJQFUxWaCpbcM0koVTopTmcp38a3Qtb+r8kjztHxiVXZW0nIt3N8t+jx9ABvOw/srBmDufAIXMM1umWa8qttkUel+Zx+0PYRNUARdsjuhYE7tSXnuCBDOnR4uzVDtBw1jaxZWh/Mj5nJ5dcHFazQLuC9KAqGjeu6SEHRmtdEgmCKXKe+/Kjjqdv7tiWH1ZpaIyVGmT0dWGGXbMI0q85wnp6nmwOjCQ1UsxGF5VmSPEb0JudH7WlLBEEUejvSAAQkN3LFJhxWQL9/apRclfYodh0DKXe0WypBvogu6wjGoFGHa/PKhBM+VqMLFJrZkxlxYta7WvU//Boithb10AbrMTqYctRysMTkuNhirvTwn0AkCV1N8HVbs9eQp/Mf0cVjaBgzLM4AZyDegRe2RUTIB97IJmmh7zTeuw+8TwM1frhXkU2YiJXGIlWyurcO35MV6ZVNG3nSUzKEtptml+JAZ0Lk12caXu7zKq+k4cDsjfamhmiLRTQvxn6b3w9+xHzzoN844AQWA2+rmmDbYrKOLOGwatJ7HUM/V5lDBjB6aesAmZ7DruVowrE3DxjhEFC/YPRHtO55Q7KWMfNFJPpPH64zCc3e25xvJXuKCTKWt1QLs6w1wccvyWbizlBcZ4rxptBWNSwjrO9aobI82gKabzSm7fHtOF0CQCNP5996XIPtlLN1Zrcq6byL8gmTd3eul/Ec16TgtSeKUMZTK2C8D72LopcTF/YcRDh68zp74X15DO6qLNsvu7amrY1cawykvA7AgV55Nyee0OumRysJii4IbERwPQRlHJ4f28ckcATdszDQMDZ/+/GNgpfB8i1+jT3i6ApTxZ7RF5dTncz4+4o4sNuJYMvv54bLN2CtY/fx7zFqwX5aAOh9oqkQdf9R/s/fqkn8pq2cs4oSOXbR/H0JlLDGsgqUHwvxUlrMXIDwqu/MqpCvcjZGjzfmiliNxkDZ93SGfB5+57ZRpol7VrMnCErbkgvnMSVsjcnzFVGPrOSj0DRmhQBjoLPMghgAsdN3/XD7JQdrX0T014qoWMdDoyGTwSexDLO0jrmiAqbqaRF1HkS/P6Lb88kHt8AtHuU3FWlVNUN6tNRZ6oaxnbcDG+8i49ZcOLM/gLJzG84X5JD5Ci6OiYO+1fC9uNFWRz9LoiZxdVJtBKItMj7IAiw4Uh6pHPQyd38yD4mtcoQXfpD+LFcEAfPRoJ2LEleB7XALYKpFBA4FFYaseSwctvCt6FTyI1LtHf3qBx3Jh5PmjxoirJxDmK8bNXFq81K27ER39+z+zXUkat7jMBWA96WIoUCRtGv4Y7VteYWu9DveqNnB7pEjDNSSWZThMcGBU42bheVlhGCcHHZSCCN3SM9eV7WtF0ktVRUmkJBNLLxsLVOvscrMd9V1zb857iU3m/v7MNZ9Cnmq4e0WeeUzXAUbPgxnIsYFc80uKBYUe1apKEaiHTfD4eR+uhy1ir7YmKhnKtXr4cvqDDzRly5Ie5RlvACVCn7NRdvUdzCfqrXKpexbYRqJu9eHDslr84FRzYIPdVPuyySfUXHaTg1HGSvBLuTTPW89Xf+tcgDe7w9o7emx/YG3bTP9cAD4rTtUKTD1Xz5LoAva+q5CLDFl5AiP9xRUm6l3W/6aGVE1xn60MIHY1hIdULijByJSsjX67psVRiZectYE7MpjwRgk7W1IeCeF2wzBpdLiSfSgrcDIk9278k7bZokXJb+CLwNIRx5AA776XEd8LXVR+LrARC/E0uYgUyhNi31Ma1x2wfLR7nnKj8S41wofABQzidwTKO692rDUHuBrMW/WlJHbPxZ3eIoNRgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11554,"cache_read_input_tokens":153798,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11554},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vb6HnYKTZ5fBNEJKcE","type":"assistant","uuid":"9d9dad29-fb8f-45a9-af29-336e40498ac0","timestamp":"2026-02-11T15:10:39.892Z"}
{"parentUuid":"9d9dad29-fb8f-45a9-af29-336e40498ac0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01AGXDuHk4z9vjzVucaJMeLB","type":"message","role":"assistant","content":[{"type":"text","text":"Good point about admin buttons for events like \"shipped\". I noted that. Now I have a complete picture of everything to change. Let me create tasks to track the work and start editing."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11554,"cache_read_input_tokens":153798,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11554},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vb6HnYKTZ5fBNEJKcE","type":"assistant","uuid":"3e65caf3-9036-4ae2-9a43-19f355040969","timestamp":"2026-02-11T15:10:39.954Z"}
{"parentUuid":"3e65caf3-9036-4ae2-9a43-19f355040969","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01AGXDuHk4z9vjzVucaJMeLB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0136c6dzpLG4RoqaHxQCfkLT","name":"TaskCreate","input":{"subject":"Update 01-DATABASE-SCHEMA.md - Add payment_method columns, update provider","description":"Add payment_method TEXT column (CHECK IN credit_card, paypal, bank_transfer) to checkouts and orders tables. Change payments.provider CHECK from (stripe, paypal) to (mock). Update related indexes.","activeForm":"Updating database schema spec"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11554,"cache_read_input_tokens":153798,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11554},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vb6HnYKTZ5fBNEJKcE","type":"assistant","uuid":"e30774e6-7ede-49fe-a63a-922bdef7ff08","timestamp":"2026-02-11T15:10:41.255Z"}
{"parentUuid":null,"logicalParentUuid":"08fa310a-6c99-4552-84e5-0d59809aa2b4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"compact_boundary","content":"Conversation compacted","isMeta":false,"timestamp":"2026-02-11T15:12:08.222Z","uuid":"b6204840-22c1-46ab-ba10-d030b917d148","level":"info","compactMetadata":{"trigger":"auto","preTokens":168168}}
{"parentUuid":"b6204840-22c1-46ab-ba10-d030b917d148","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nAnalysis:\nLet me chronologically analyze the conversation:\n\n1. **User's first request**: \"Write a summary of the specs\" - User wanted a summary of all specification files in the `specs/` directory.\n\n2. **My action**: Read all 9 spec files (01-09). Some files were too large to read in one go, so I read them in chunks. I provided a comprehensive summary of all specs describing a Shopify-clone e-commerce platform.\n\n3. **User's second request**: \"Describe the functionality as user stories (not epic by epic; the big picture)\" - User wanted user stories organized by actor/persona rather than by technical epic.\n\n4. **My action**: Synthesized all spec knowledge into user stories organized by: Store Owner, Admin/Staff (Products, Orders, Discounts, Shipping/Taxes, Content/Themes, Analytics, Apps), Customer (Browsing, Cart, Checkout, Account), and Platform (cross-cutting).\n\n5. **User's third request**: Three questions:\n   - \"Do we cover seed data for a demoshop? If yes, what kind of products and how many? Are all capabilities of our shop covered by the demo data?\"\n   - \"And PSP is dummy, right? What payment methods and what shipment methods?\"\n\n6. **My action**: Read detailed seed data sections (spec 07) and business logic sections (spec 05). Provided detailed answers:\n   - 20 products for Acme Fashion (fashion/apparel), 5 for Acme Electronics (tenant isolation)\n   - Listed all products with special test purposes (draft, archived, sold out, backorder, digital, expensive)\n   - Explained PSP is Stripe test mode + PayPal stub\n   - Detailed shipping zones (Domestic DE, EU, Rest of World) with flat rates only\n   - Listed gaps in seed data coverage\n\n7. **User's fourth request**: \"ok, I want an onsite checkout incl payment selection. Fake pre-auth and capture. No real PSP interaction. It must fully work with a PSP mock.\"\n   Plus: \"Do we have an order management system? How is the flow?\"\n   Plus: \"What discounts do we have in demo data?\"\n\n8. **My action**: \n   - Answered the discount question (5 codes: WELCOME10, FLAT5, FREESHIP, EXPIRED20, MAXED)\n   - Described the OMS flow (3 status axes, admin capabilities)\n   - Noted this is a CHANGE from current spec (which uses Stripe redirect)\n   - Asked clarifying questions via AskUserQuestion about payment methods, failure testing, and capture timing\n\n9. **User's answers to questions**:\n   - Payment methods: Card + PayPal + Bank Transfer (3 options)\n   - Failure testing: Yes, magic numbers\n   - Capture timing: Auto-capture on checkout\n   - Then said: \"don't change specs yet\"\n\n10. **My action**: Summarized the agreed-upon design without making changes.\n\n11. **User's fifth request**: \"OMS workflow per payment method and shipment method is a bit different. do we support this?\"\n\n12. **My action**: Analyzed the current spec and identified gaps:\n    - Current spec treats all payments as instant (no bank transfer deferred flow)\n    - No \"pending payment\" order state for bank transfer\n    - No admin \"confirm payment\" action\n    - No auto-cancel for unpaid orders\n    - No auto-fulfillment for digital products\n    - Presented a table showing what differs per payment method and per shipment method\n\n13. **User's sixth request**: \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\"\n\n14. **User's additional note** (sent while I was working): \"Events (like 'shipped') are triggered by admin buttons\"\n\n15. **My action**: Started reading all relevant sections across spec files to understand the full scope of changes needed. Read:\n    - 05-BUSINESS-LOGIC.md sections 10-13 (Payment, OMS, Customer)\n    - 02-API-ROUTES.md sections on checkout and payment endpoints (lines 415-956)\n    - 03-ADMIN-UI.md order detail section (lines 936-1107)\n    - 04-STOREFRONT-UI.md checkout section (lines 938-1094)\n    \n    Used an Explore agent to find exact line numbers.\n\n16. **Created first task**: \"Update 01-DATABASE-SCHEMA.md - Add payment_method columns, update provider\"\n\nThe comprehensive changes needed across all spec files:\n\n**01-DATABASE-SCHEMA.md**: Add payment_method to checkouts and orders tables, change payments.provider CHECK\n\n**02-API-ROUTES.md**: Replace Stripe/PayPal endpoints with mock payment endpoint, update checkout flow diagram\n\n**03-ADMIN-UI.md**: Add \"Confirm Payment\" button, fulfillment guard, \"Mark as shipped\"/\"Mark as delivered\" buttons\n\n**04-STOREFRONT-UI.md**: Replace payment step with method selection + onsite forms\n\n**05-BUSINESS-LOGIC.md**: Replace Section 10 with MockPSP, update Section 11 with payment-method-specific OMS, add auto-fulfillment for digital, add auto-cancel for bank transfer, add fulfillment guard\n\n**07-SEEDERS-AND-TEST-DATA.md**: Update seeded orders with payment methods, add bank transfer order\n\n**08-PLAYWRIGHT-E2E-PLAN.md**: Update E2E tests for new flows\n\n**09-IMPLEMENTATION-ROADMAP.md**: Update phases\n\nKey design decisions agreed upon:\n- 3 payment methods: Credit Card, PayPal, Bank Transfer (all mocked)\n- Magic card numbers: 4242...4242 = success, 4000...0002 = decline, 4000...9995 = insufficient funds\n- Auto-capture for Card/PayPal, deferred payment for Bank Transfer\n- Bank Transfer: order created with financial_status=pending, inventory reserved, admin confirms payment, auto-cancel after configurable days\n- Digital products: auto-fulfillment on payment confirmation\n- Fulfillment guard: cannot ship while financial_status=pending\n- Fulfillment status transitions (shipped, delivered) are explicit admin button actions\n\nSummary:\n1. Primary Request and Intent:\n   The user is working on a Shopify-clone e-commerce platform built with Laravel 12, Livewire v4, Flux UI Free, Tailwind CSS v4, and SQLite. The conversation progressed through several phases:\n   \n   - **Phase 1 (Research)**: User asked for a summary of all spec files, then user stories, then details about seed data, PSP, and shipping.\n   - **Phase 2 (Design Decisions)**: User requested an onsite checkout with payment selection using a mock PSP (no real Stripe/PayPal). Three payment methods: Credit Card, PayPal, Bank Transfer. Magic card numbers for testing. Auto-capture on checkout.\n   - **Phase 3 (OMS Requirements)**: User asked whether OMS workflow differs per payment method and shipment method. After I identified gaps, user explicitly requested: **\"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\"**. User also clarified: **\"Events (like 'shipped') are triggered by admin buttons\"**.\n   \n   The current active task is to **modify all 9 spec files** to implement the onsite mock PSP checkout with payment-method-specific OMS workflows.\n\n2. Key Technical Concepts:\n   - **Laravel 12** with Livewire v4, Flux UI Free, Tailwind CSS v4, SQLite (WAL mode)\n   - **Multi-tenant architecture**: Organizations -> Stores, store-scoped data via global query scopes\n   - **Mock PSP**: Replaces Stripe/PayPal with a `MockPaymentProvider` - no external API calls, no webhooks\n   - **3 Payment Methods**: `credit_card`, `paypal`, `bank_transfer` - each with different OMS workflow\n   - **Magic card numbers**: `4242 4242 4242 4242` (success), `4000 0000 0000 0002` (decline), `4000 0000 0000 9995` (insufficient funds)\n   - **Payment-method-specific OMS**:\n     - Card/PayPal: instant auth+capture, order created as `paid`, fulfillment allowed immediately\n     - Bank Transfer: order created as `pending`, inventory reserved (not committed), admin confirms payment manually, auto-cancel after configurable days\n   - **Shipment-method-specific OMS**:\n     - Physical products: manual fulfillment (admin creates fulfillment, marks as shipped, marks as delivered via buttons)\n     - Digital products: auto-fulfillment on payment confirmation\n   - **Fulfillment guard**: Cannot create fulfillment unless `financial_status` is `paid` or `partially_refunded`\n   - **Auto-cancel job**: Cancels unpaid bank transfer orders after configurable number of days\n   - **Onsite checkout**: No external redirects, payment form rendered within the storefront\n   - **Pricing**: All amounts in minor units (cents), EUR currency, 19% tax (manual mode, tax-inclusive)\n\n3. Files and Code Sections:\n   - **specs/01-DATABASE-SCHEMA.md** (read fully, ~1678 lines)\n     - Defines 46 application tables + 9 Laravel framework tables\n     - **Needs modification**: Add `payment_method` TEXT column (CHECK IN 'credit_card', 'paypal', 'bank_transfer') to `checkouts` table and `orders` table. Change `payments.provider` CHECK from ('stripe', 'paypal') to ('mock').\n   \n   - **specs/02-API-ROUTES.md** (read lines 1-965)\n     - Defines all routes: admin auth, admin CRUD (Livewire), storefront (Blade+Livewire), REST API\n     - Checkout endpoints at lines 415-809, Payment endpoints at lines 811-956\n     - **Needs modification**: Replace Stripe/PayPal payment endpoints with single mock payment endpoint `POST /checkouts/{id}/pay`. Remove webhook endpoints. Update checkout flow sequence diagram.\n   \n   - **specs/03-ADMIN-UI.md** (read lines 1-1115)\n     - Admin panel spec: sidebar, dashboard, products, orders, etc.\n     - Order detail section at lines 936-1107 with fulfillment modal and refund modal\n     - **Needs modification**: Add \"Confirm Payment\" button (shown when payment_method=bank_transfer AND financial_status=pending). Add fulfillment guard messaging. Add \"Mark as shipped\" and \"Mark as delivered\" buttons on existing fulfillments. Show payment_method in payment details.\n   \n   - **specs/04-STOREFRONT-UI.md** (read lines 1-500, 938-1094)\n     - Storefront UI: layout, home, collections, products, cart, checkout, account\n     - Checkout section at lines 938-1094 with 4-step flow\n     - **Needs modification**: Replace Step 4 (Payment) from Stripe redirect to payment method selection with onsite forms (fake card fields, PayPal button, bank transfer details).\n   \n   - **specs/05-BUSINESS-LOGIC.md** (read lines 1-1296)\n     - Core business logic: tenancy, products, inventory, cart, pricing, checkout state machine, discounts, tax, shipping, payments, OMS, customers, webhooks\n     - Payment Processing at Section 10 (lines ~905-997): Stripe + PayPal integration\n     - OMS at Section 11 (lines ~999-1160): Order creation, numbering, statuses, refunds, fulfillment\n     - **Needs major modification**: Replace Section 10 with MockPaymentProvider. Update Section 11 with payment-method-specific workflows, fulfillment guard, auto-fulfillment for digital, auto-cancel for bank transfer, admin confirm-payment action. Note that fulfillment transitions (shipped, delivered) are admin button actions.\n   \n   - **specs/06-AUTH-AND-SECURITY.md** (read lines 1-100)\n     - Three auth mechanisms: session-based admin, session-based customer (store-scoped), Sanctum API\n     - No changes needed\n   \n   - **specs/07-SEEDERS-AND-TEST-DATA.md** (read lines 1-1795)\n     - 18 seeders, 20 products for Acme Fashion, 5 for Acme Electronics\n     - 15 seeded orders (#1001-#1015) covering various statuses\n     - 5 discount codes: WELCOME10 (10% off, min 20 EUR), FLAT5 (5 EUR off), FREESHIP (free shipping), EXPIRED20 (expired), MAXED (usage limit reached)\n     - **Needs modification**: Add payment_method to all existing orders. Change most to credit_card. Add bank transfer order (pending payment). Add PayPal order. Update PaymentFactory provider to 'mock'.\n   \n   - **specs/08-PLAYWRIGHT-E2E-PLAN.md** (read lines 1-100)\n     - 134 browser tests across 18 test files using Pest v4\n     - **Needs modification**: Update checkout flow tests for payment method selection, add tests for each payment method, magic card decline, bank transfer admin confirmation.\n   \n   - **specs/09-IMPLEMENTATION-ROADMAP.md** (read lines 1-199)\n     - 12 sequential phases from Foundation to Full Test Suite\n     - **Needs modification**: Update Phase 5 (Payments) to use mock PSP, add OMS workflow differentiation.\n\n4. Errors and fixes:\n   - **File too large error**: When trying to read all 9 spec files in parallel, 02-API-ROUTES.md exceeded the 25000 token limit. Fixed by reading files individually with offset/limit parameters.\n   - **Sibling tool call errors**: When one parallel read failed, subsequent sibling reads also errored. Fixed by making separate sequential read calls with limits.\n   - **User correction \"don't change specs yet\"**: User sent this while I was working after answering the AskUserQuestion. I respected this and only summarized the design without making edits. Later user explicitly asked to adjust specs.\n\n5. Problem Solving:\n   - Identified that the current spec treats all payments as instant (Stripe redirect model), which breaks for Bank Transfer\n   - Identified missing OMS differentiation per payment method (no pending payment state, no admin confirm, no auto-cancel)\n   - Identified missing OMS differentiation per shipment method (no auto-fulfillment for digital products)\n   - Identified missing fulfillment guard (should not ship before payment confirmed)\n   - Designed a comprehensive solution covering all these gaps\n\n6. All user messages:\n   - \"Write a summary of the specs\"\n   - \"Describe the functionality as user stories (not epic by epic; the big picture)\"\n   - \"Do we cover a seed data for a demoshop? If yes, what kind of products and how many? Are all capabilities of our shop covered by the demo data? And PSP is dummy, right? What payment methods and what shipment methods?\"\n   - \"ok, I want an onsite checkout incl paymnt selection. Fake pre-auth and capture. No real PSP interation. It must fully work with a PSP mock. Do we have a order management system? How is the flow? What idscounts do we have in demo data?\"\n   - [AskUserQuestion answers]: Card + PayPal + Bank Transfer; Yes magic numbers; Auto-capture on checkout\n   - \"don't change specs yet\"\n   - \"OMS workflow per payment method and shipment method is a bit different. do we support this?\"\n   - \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\"\n   - \"Events (like 'shipped') are triggerd by admin buttnos\"\n\n7. Pending Tasks:\n   - **Task #1 created**: \"Update 01-DATABASE-SCHEMA.md - Add payment_method columns, update provider\" (status: pending)\n   - **Not yet created but planned**:\n     - Update 02-API-ROUTES.md - Replace Stripe/PayPal endpoints with mock payment flow\n     - Update 03-ADMIN-UI.md - Add Confirm Payment button, fulfillment guard, shipped/delivered buttons\n     - Update 04-STOREFRONT-UI.md - Replace checkout Step 4 with payment method selection\n     - Update 05-BUSINESS-LOGIC.md - Mock PSP, payment-method-specific OMS, auto-fulfillment, auto-cancel, fulfillment guard\n     - Update 07-SEEDERS-AND-TEST-DATA.md - Add payment methods to seeded orders\n     - Update 08-PLAYWRIGHT-E2E-PLAN.md - Update E2E tests for new checkout/payment flows\n     - Update 09-IMPLEMENTATION-ROADMAP.md - Update implementation phases\n\n8. Current Work:\n   I was in the process of beginning to edit all 9 spec files based on the user's request \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\". I had:\n   1. Read all relevant sections across all spec files to understand the full scope\n   2. Identified exact line ranges for each section that needs changes\n   3. Created the first TaskCreate for tracking (Task #1: Update 01-DATABASE-SCHEMA.md)\n   4. Was about to create remaining tasks and start making edits in parallel\n   \n   The agreed-upon design changes are:\n   - **Mock PSP** with 3 payment methods (credit_card, paypal, bank_transfer), magic card numbers, onsite checkout\n   - **Payment-method-specific OMS**: Card/PayPal = instant capture + order as paid; Bank Transfer = deferred payment + order as pending + admin confirms + auto-cancel\n   - **Shipment-method-specific OMS**: Physical = manual fulfillment via admin buttons (create fulfillment -> mark shipped -> mark delivered); Digital = auto-fulfillment on payment\n   - **Fulfillment guard**: Block fulfillment creation when financial_status = pending\n   - **Admin buttons** trigger events like shipped and delivered (per user's explicit instruction)\n\n9. Optional Next Step:\n   Continue editing all 9 spec files as requested. The user's explicit request was: **\"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\"** and **\"Events (like 'shipped') are triggered by admin buttons\"**.\n   \n   I had just created Task #1 and was about to create the remaining tasks and begin the actual file edits. The first edit should be to `specs/01-DATABASE-SCHEMA.md` (add payment_method columns) and `specs/05-BUSINESS-LOGIC.md` (replace payment processing and update OMS), as these are the foundational changes that inform all other spec updates.\n\nIf you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/fabianwesner/.claude/projects/-Users-fabianwesner-Herd-shop/17a2a457-7a36-4d51-927a-aa26d07a423b.jsonl\nPlease continue the conversation from where we left off without asking the user any further questions. Continue with the last task that you were asked to work on."},"isVisibleInTranscriptOnly":true,"isCompactSummary":true,"uuid":"a343c70e-165c-4e68-9193-d7cdd236b618","timestamp":"2026-02-11T15:12:08.222Z"}
{"parentUuid":"a343c70e-165c-4e68-9193-d7cdd236b618","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01X1P8bmYqJUJy6jG9YqKwb7","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26044,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26044},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vmx1WxappXPmC11uYC","type":"assistant","uuid":"9951d02a-a72e-4143-ad36-b5b167ce8827","timestamp":"2026-02-11T15:12:10.510Z"}
{"parentUuid":"9951d02a-a72e-4143-ad36-b5b167ce8827","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01X1P8bmYqJUJy6jG9YqKwb7","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to continue editing all 9 spec files based on the agreed-upon design changes. Let me first read the current state of the spec files I need to modify, then make all the edits. Let me start by reading the key files to understand exactly what needs to change.\n\nLet me create all the remaining tasks first, then start editing files in parallel.\n\nActually, let me first check the current task list and then read the files I need to edit.","signature":"EukECkYICxgCKkDvF3WVrWxJyMJoZaaHRt+O0o1fliBBf+YiDmbXcnoDB4DCAlkIT6NvUaT/FVgSXmmMc6rATTZDQ3tQnGi914vGEgxIM9LcOKi9FNZ8C8AaDOMyaYOTPctT70fJwiIw6R8Ny+yuaxuDe+pmw/dIwPgbJKrXGbtje/FevDewAi+lX4bdSTaQwAghh+jVR1mgKtADiBX8k3IJ+D9kOncQVApgmwwM8qxCkq3fnheyH3VNG8feaJ85vfrLb4daaJ83LVgx0xBXOPmzkTvaAPTHjztm1PL0CzY8Y+aZTkDxZPnLbP7kTNdcYuhJ7hn6X9puTFaV55T1eCX0nTbcLv+FVNQUAXhQ/0NnesiURppgkoffFEMEe2vSa0Ta/tl1ZVD777qNPc2k4KnKsxFeBZEiNyTnj3JKaZ5XI/gW5UhT/pZXEM5tqvC47G402mqZp80gNvyOcE6xrVnHln8/qqTLVL/L2v94zUH4Xgs45TRdlSjaLlpavJ2Z9mBYZHsjDrVSWG5pHt+D8DO2gAt4fgUoYTmgoAm0LzXEIr1/6TAvCq7kfaEa17CL5JW2ZAfMz2OUs+UFEQcrtwW+0QneVwMuCC63G45Hj8Y0MdzSG2tGiaktk/yCeswsodr3lUgo7c7dvz8aKqJciAu54K0WAcke7Ig4tsUh0KgiOHR9sBa69xSI8wILzmxQWYBSDcixR/BOylZMo0umTJA3o+HC4SB9aTQDNWgwNr/rvJndkKY4cvuLQ1iHSrtzUp8MDnSfXpa7Ge8jaFHsBF67n377c0QFnXGpu8Hc5/pXgtfKvdyPMQ8T5bwYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26044,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26044},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vmx1WxappXPmC11uYC","type":"assistant","uuid":"e2b856c6-8767-4aba-808e-008379ce4a84","timestamp":"2026-02-11T15:12:12.598Z"}
{"parentUuid":"e2b856c6-8767-4aba-808e-008379ce4a84","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01X1P8bmYqJUJy6jG9YqKwb7","type":"message","role":"assistant","content":[{"type":"text","text":"I'll continue updating all spec files with the agreed-upon mock PSP checkout and payment-method-specific OMS design. Let me first check the current task state and read the files I need to modify."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26044,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26044},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vmx1WxappXPmC11uYC","type":"assistant","uuid":"84968a29-f953-4c43-a6fd-b77c2cfdb29c","timestamp":"2026-02-11T15:12:13.710Z"}
{"parentUuid":"84968a29-f953-4c43-a6fd-b77c2cfdb29c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01X1P8bmYqJUJy6jG9YqKwb7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NXrt131desyL2gpoouAgtN","name":"TaskList","input":{}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26044,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26044},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vmx1WxappXPmC11uYC","type":"assistant","uuid":"087fa5cb-537f-43c1-a9a4-7ba5562ac269","timestamp":"2026-02-11T15:12:13.833Z"}
{"parentUuid":"087fa5cb-537f-43c1-a9a4-7ba5562ac269","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NXrt131desyL2gpoouAgtN","type":"tool_result","content":"#1 [pending] Update 01-DATABASE-SCHEMA.md - Add payment_method columns, update provider"}]},"uuid":"6e78e79a-cb94-43be-a016-dd73f8d191a7","timestamp":"2026-02-11T15:12:13.837Z","toolUseResult":{"tasks":[{"id":"1","subject":"Update 01-DATABASE-SCHEMA.md - Add payment_method columns, update provider","status":"pending","blockedBy":[]}]},"sourceToolAssistantUUID":"087fa5cb-537f-43c1-a9a4-7ba5562ac269"}
{"parentUuid":"6e78e79a-cb94-43be-a016-dd73f8d191a7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01X1P8bmYqJUJy6jG9YqKwb7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01W9U3oegZNd1LUgUhiz8SdZ","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26044,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26044},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vmx1WxappXPmC11uYC","type":"assistant","uuid":"5f367984-0959-411d-9f2a-ce1bc4d27506","timestamp":"2026-02-11T15:12:14.385Z"}
{"parentUuid":"5f367984-0959-411d-9f2a-ce1bc4d27506","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01W9U3oegZNd1LUgUhiz8SdZ","toolUseID":"toolu_01W9U3oegZNd1LUgUhiz8SdZ","timestamp":"2026-02-11T15:12:14.572Z","uuid":"aa624c76-67a9-4b0e-a8ab-e0a9f13c4d21"}
{"parentUuid":"5f367984-0959-411d-9f2a-ce1bc4d27506","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01W9U3oegZNd1LUgUhiz8SdZ","type":"tool_result","content":"     1→# 01 - Database Schema Specification\n     2→\n     3→> Shopify-clone 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_pending', 'completed', 'expired') | |\n   588→| email | TEXT | Yes | NULL | - | Contact email |\n   589→| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n   590→| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n   591→| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n   592→| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n   593→| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n   594→| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n   595→| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n   596→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   597→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   598→\n   599→**Indexes:**\n   600→\n   601→- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n   602→- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n   603→- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n   604→- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n   605→- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n   606→\n   607→**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   608→\n   609→---\n   610→\n   611→### shipping_zones\n   612→\n   613→Geographic shipping zones for a store.\n   614→\n   615→| Column | Type | Nullable | Default | Constraints | Notes |\n   616→|---|---|---|---|---|---|\n   617→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   618→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   619→| name | TEXT | No | - | - | Zone display name |\n   620→| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n   621→| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n   622→\n   623→**Indexes:**\n   624→\n   625→- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n   626→\n   627→**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n   628→\n   629→---\n   630→\n   631→### shipping_rates\n   632→\n   633→Shipping rate options within a zone.\n   634→\n   635→| Column | Type | Nullable | Default | Constraints | Notes |\n   636→|---|---|---|---|---|---|\n   637→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   638→| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n   639→| name | TEXT | No | - | - | Rate display name |\n   640→| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n   641→| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n   642→| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n   643→\n   644→**Indexes:**\n   645→\n   646→- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n   647→- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n   648→\n   649→**Notes:** `config_json` structure depends on `type`:\n   650→- Flat: `{\"amount\": 799}`\n   651→- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n   652→- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n   653→- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n   654→\n   655→---\n   656→\n   657→### tax_settings\n   658→\n   659→Per-store tax configuration.\n   660→\n   661→| Column | Type | Nullable | Default | Constraints | Notes |\n   662→|---|---|---|---|---|---|\n   663→| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n   664→| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n   665→| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n   666→| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n   667→| config_json | TEXT | No | '{}' | - | Provider-specific config |\n   668→\n   669→---\n   670→\n   671→### discounts\n   672→\n   673→Discount codes and automatic discounts.\n   674→\n   675→| Column | Type | Nullable | Default | Constraints | Notes |\n   676→|---|---|---|---|---|---|\n   677→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   678→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   679→| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n   680→| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n   681→| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n   682→| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n   683→| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n   684→| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n   685→| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n   686→| usage_count | INTEGER | No | 0 | - | Current redemption count |\n   687→| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n   688→| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n   689→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   690→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   691→\n   692→**Indexes:**\n   693→\n   694→- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n   695→- `idx_discounts_store_id` on (`store_id`) - FK lookup\n   696→- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n   697→- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n   698→\n   699→**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   700→\n   701→---\n   702→\n   703→## Epic 6 - Payments, Orders, Fulfillment, and Customers\n   704→\n   705→### customers\n   706→\n   707→Storefront customer accounts, scoped to a store.\n   708→\n   709→| Column | Type | Nullable | Default | Constraints | Notes |\n   710→|---|---|---|---|---|---|\n   711→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   712→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   713→| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n   714→| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n   715→| name | TEXT | Yes | NULL | - | Display name |\n   716→| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n   717→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   718→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   719→\n   720→**Indexes:**\n   721→\n   722→- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n   723→- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n   724→\n   725→**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n   726→\n   727→---\n   728→\n   729→### customer_addresses\n   730→\n   731→Saved addresses for a customer.\n   732→\n   733→| Column | Type | Nullable | Default | Constraints | Notes |\n   734→|---|---|---|---|---|---|\n   735→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   736→| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n   737→| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n   738→| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n   739→| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n   740→\n   741→**Indexes:**\n   742→\n   743→- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n   744→- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n   745→\n   746→**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n   747→\n   748→---\n   749→\n   750→### orders\n   751→\n   752→Placed orders with financial and fulfillment tracking.\n   753→\n   754→| Column | Type | Nullable | Default | Constraints | Notes |\n   755→|---|---|---|---|---|---|\n   756→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   757→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   758→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n   759→| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n   760→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n   761→| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n   762→| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n   763→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n   764→| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n   765→| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n   766→| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n   767→| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n   768→| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n   769→| email | TEXT | Yes | NULL | - | Contact email at time of order |\n   770→| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n   771→| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n   772→| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n   773→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   774→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   775→\n   776→**Indexes:**\n   777→\n   778→- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n   779→- `idx_orders_store_id` on (`store_id`) - FK lookup\n   780→- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n   781→- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n   782→- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n   783→- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n   784→- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n   785→\n   786→---\n   787→\n   788→### order_lines\n   789→\n   790→Individual items within an order. Contains snapshots of product data at time of purchase.\n   791→\n   792→| Column | Type | Nullable | Default | Constraints | Notes |\n   793→|---|---|---|---|---|---|\n   794→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   795→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   796→| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n   797→| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n   798→| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n   799→| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n   800→| quantity | INTEGER | No | 1 | - | Number of units |\n   801→| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n   802→| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n   803→| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n   804→| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n   805→\n   806→**Indexes:**\n   807→\n   808→- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n   809→- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n   810→- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n   811→\n   812→**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   813→\n   814→---\n   815→\n   816→### payments\n   817→\n   818→Payment attempts and captures for an order.\n   819→\n   820→| Column | Type | Nullable | Default | Constraints | Notes |\n   821→|---|---|---|---|---|---|\n   822→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   823→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   824→| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n   825→| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |\n   826→| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n   827→| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n   828→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n   829→| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n   830→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   831→\n   832→**Indexes:**\n   833→\n   834→- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n   835→- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID\n   836→- `idx_payments_status` on (`status`) - Filtering payments by status\n   837→\n   838→**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n   839→\n   840→---\n   841→\n   842→### refunds\n   843→\n   844→Refund records linked to an order and payment.\n   845→\n   846→| Column | Type | Nullable | Default | Constraints | Notes |\n   847→|---|---|---|---|---|---|\n   848→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   849→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   850→| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n   851→| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n   852→| reason | TEXT | Yes | NULL | - | Refund reason |\n   853→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n   854→| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n   855→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   856→\n   857→**Indexes:**\n   858→\n   859→- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n   860→- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n   861→- `idx_refunds_status` on (`status`) - Filtering refunds by status\n   862→\n   863→---\n   864→\n   865→### fulfillments\n   866→\n   867→Shipment tracking for order fulfillment.\n   868→\n   869→| Column | Type | Nullable | Default | Constraints | Notes |\n   870→|---|---|---|---|---|---|\n   871→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   872→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   873→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n   874→| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n   875→| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n   876→| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n   877→| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   878→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   879→\n   880→**Indexes:**\n   881→\n   882→- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n   883→- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n   884→- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n   885→\n   886→---\n   887→\n   888→### fulfillment_lines\n   889→\n   890→Links specific order lines and quantities to a fulfillment.\n   891→\n   892→| Column | Type | Nullable | Default | Constraints | Notes |\n   893→|---|---|---|---|---|---|\n   894→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   895→| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n   896→| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n   897→| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n   898→\n   899→**Indexes:**\n   900→\n   901→- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n   902→- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n   903→\n   904→**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   905→\n   906→---\n   907→\n   908→## Epic 7 - Analytics\n   909→\n   910→### analytics_events\n   911→\n   912→Raw event stream for storefront analytics.\n   913→\n   914→| Column | Type | Nullable | Default | Constraints | Notes |\n   915→|---|---|---|---|---|---|\n   916→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   917→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   918→| type | TEXT | No | - | - | Event type (see notes) |\n   919→| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n   920→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n   921→| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n   922→| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n   923→| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n   924→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   925→\n   926→**Indexes:**\n   927→\n   928→- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n   929→- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n   930→- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n   931→- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n   932→- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n   933→- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n   934→\n   935→**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   936→\n   937→---\n   938→\n   939→### analytics_daily\n   940→\n   941→Pre-aggregated daily metrics per store. Composite primary key.\n   942→\n   943→| Column | Type | Nullable | Default | Constraints | Notes |\n   944→|---|---|---|---|---|---|\n   945→| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n   946→| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n   947→| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n   948→| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n   949→| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n   950→| visits_count | INTEGER | No | 0 | - | Unique sessions |\n   951→| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n   952→| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n   953→| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n   954→\n   955→**Primary Key:** (`store_id`, `date`)\n   956→\n   957→**Indexes:**\n   958→\n   959→- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n   960→\n   961→**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   962→\n   963→---\n   964→\n   965→## Epic 8 - Apps and Webhooks\n   966→\n   967→### apps\n   968→\n   969→Third-party or first-party application registry.\n   970→\n   971→| Column | Type | Nullable | Default | Constraints | Notes |\n   972→|---|---|---|---|---|---|\n   973→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   974→| name | TEXT | No | - | - | App display name |\n   975→| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n   976→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   977→\n   978→**Indexes:**\n   979→\n   980→- `idx_apps_status` on (`status`) - Filtering apps by status\n   981→\n   982→---\n   983→\n   984→### app_installations\n   985→\n   986→Installation of an app on a specific store.\n   987→\n   988→| Column | Type | Nullable | Default | Constraints | Notes |\n   989→|---|---|---|---|---|---|\n   990→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   991→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n   992→| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n   993→| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n   994→| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n   995→| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   996→\n   997→**Indexes:**\n   998→\n   999→- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n  1000→- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n  1001→- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n  1002→\n  1003→---\n  1004→\n  1005→### oauth_clients\n  1006→\n  1007→OAuth2 client credentials for apps.\n  1008→\n  1009→| Column | Type | Nullable | Default | Constraints | Notes |\n  1010→|---|---|---|---|---|---|\n  1011→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1012→| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n  1013→| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n  1014→| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n  1015→| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n  1016→\n  1017→**Indexes:**\n  1018→\n  1019→- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n  1020→- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n  1021→\n  1022→---\n  1023→\n  1024→### oauth_tokens\n  1025→\n  1026→Issued access/refresh tokens for app installations.\n  1027→\n  1028→| Column | Type | Nullable | Default | Constraints | Notes |\n  1029→|---|---|---|---|---|---|\n  1030→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1031→| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n  1032→| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n  1033→| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n  1034→| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n  1035→\n  1036→**Indexes:**\n  1037→\n  1038→- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n  1039→- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n  1040→- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n  1041→\n  1042→---\n  1043→\n  1044→### webhook_subscriptions\n  1045→\n  1046→Registered webhook endpoints for event delivery.\n  1047→\n  1048→| Column | Type | Nullable | Default | Constraints | Notes |\n  1049→|---|---|---|---|---|---|\n  1050→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1051→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n  1052→| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n  1053→| event_type | TEXT | No | - | - | Event to subscribe to |\n  1054→| target_url | TEXT | No | - | - | Delivery endpoint URL |\n  1055→| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n  1056→| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n  1057→\n  1058→**Indexes:**\n  1059→\n  1060→- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n  1061→- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n  1062→- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n  1063→\n  1064→---\n  1065→\n  1066→### webhook_deliveries\n  1067→\n  1068→Delivery log for webhook attempts.\n  1069→\n  1070→| Column | Type | Nullable | Default | Constraints | Notes |\n  1071→|---|---|---|---|---|---|\n  1072→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1073→| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n  1074→| event_id | TEXT | No | - | - | Unique event identifier |\n  1075→| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n  1076→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n  1077→| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1078→| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n  1079→| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n  1080→\n  1081→**Indexes:**\n  1082→\n  1083→- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n  1084→- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n  1085→- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n  1086→- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n  1087→\n  1088→---\n  1089→\n  1090→## Laravel Framework Tables\n  1091→\n  1092→The following tables are managed by Laravel's built-in migrations and should not be modified.\n  1093→\n  1094→### sessions\n  1095→\n  1096→| Column | Type | Nullable | Default | Constraints | Notes |\n  1097→|---|---|---|---|---|---|\n  1098→| id | TEXT | No | - | PRIMARY KEY | Session ID |\n  1099→| user_id | INTEGER | Yes | NULL | - | Associated user |\n  1100→| ip_address | TEXT | Yes | NULL | - | Client IP |\n  1101→| user_agent | TEXT | Yes | NULL | - | Client user agent |\n  1102→| payload | TEXT | No | - | - | Serialized session data |\n  1103→| last_activity | INTEGER | No | - | - | Unix timestamp |\n  1104→\n  1105→### cache\n  1106→\n  1107→| Column | Type | Nullable | Default | Constraints | Notes |\n  1108→|---|---|---|---|---|---|\n  1109→| key | TEXT | No | - | PRIMARY KEY | Cache key |\n  1110→| value | TEXT | No | - | - | Serialized cache value |\n  1111→| expiration | INTEGER | No | - | - | Unix timestamp |\n  1112→\n  1113→### cache_locks\n  1114→\n  1115→| Column | Type | Nullable | Default | Constraints | Notes |\n  1116→|---|---|---|---|---|---|\n  1117→| key | TEXT | No | - | PRIMARY KEY | Lock key |\n  1118→| owner | TEXT | No | - | - | Lock owner identifier |\n  1119→| expiration | INTEGER | No | - | - | Unix timestamp |\n  1120→\n  1121→### jobs\n  1122→\n  1123→| Column | Type | Nullable | Default | Constraints | Notes |\n  1124→|---|---|---|---|---|---|\n  1125→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1126→| queue | TEXT | No | - | - | Queue name |\n  1127→| payload | TEXT | No | - | - | Serialized job payload |\n  1128→| attempts | INTEGER | No | - | - | Number of attempts |\n  1129→| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1130→| available_at | INTEGER | No | - | - | Unix timestamp |\n  1131→| created_at | INTEGER | No | - | - | Unix timestamp |\n  1132→\n  1133→### job_batches\n  1134→\n  1135→| Column | Type | Nullable | Default | Constraints | Notes |\n  1136→|---|---|---|---|---|---|\n  1137→| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n  1138→| name | TEXT | No | - | - | Batch name |\n  1139→| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n  1140→| pending_jobs | INTEGER | No | - | - | Pending count |\n  1141→| failed_jobs | INTEGER | No | - | - | Failed count |\n  1142→| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n  1143→| options | TEXT | Yes | NULL | - | Serialized batch options |\n  1144→| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1145→| created_at | INTEGER | No | - | - | Unix timestamp |\n  1146→| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1147→\n  1148→### failed_jobs\n  1149→\n  1150→| Column | Type | Nullable | Default | Constraints | Notes |\n  1151→|---|---|---|---|---|---|\n  1152→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1153→| uuid | TEXT | No | - | UNIQUE | Job UUID |\n  1154→| connection | TEXT | No | - | - | Queue connection name |\n  1155→| queue | TEXT | No | - | - | Queue name |\n  1156→| payload | TEXT | No | - | - | Serialized job payload |\n  1157→| exception | TEXT | No | - | - | Exception trace |\n  1158→| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n  1159→\n  1160→### personal_access_tokens\n  1161→\n  1162→| Column | Type | Nullable | Default | Constraints | Notes |\n  1163→|---|---|---|---|---|---|\n  1164→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1165→| tokenable_type | TEXT | No | - | - | Polymorphic type |\n  1166→| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n  1167→| name | TEXT | No | - | - | Token name |\n  1168→| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n  1169→| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n  1170→| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1171→| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1172→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1173→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1174→\n  1175→### password_reset_tokens\n  1176→\n  1177→| Column | Type | Nullable | Default | Constraints | Notes |\n  1178→|---|---|---|---|---|---|\n  1179→| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n  1180→| token | TEXT | No | - | - | Hashed reset token |\n  1181→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1182→\n  1183→---\n  1184→\n  1185→### customer_password_reset_tokens\n  1186→\n  1187→| Column | Type | Nullable | Default | Constraints | Notes |\n  1188→|---|---|---|---|---|---|\n  1189→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n  1190→| email | TEXT | No | - | - | Customer email |\n  1191→| token | TEXT | No | - | - | Hashed reset token |\n  1192→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1193→\n  1194→**Primary Key:** (`store_id`, `email`)\n  1195→\n  1196→---\n  1197→\n  1198→## Entity Relationship Diagrams\n  1199→\n  1200→### Epic 1 - Foundation and Tenancy\n  1201→\n  1202→```mermaid\n  1203→erDiagram\n  1204→    organizations ||--o{ stores : \"owns\"\n  1205→    stores ||--o{ store_domains : \"has\"\n  1206→    stores ||--o{ store_users : \"links\"\n  1207→    users ||--o{ store_users : \"links\"\n  1208→    stores ||--|| store_settings : \"has\"\n  1209→\n  1210→    organizations {\n  1211→        INTEGER id PK\n  1212→        TEXT name\n  1213→        TEXT billing_email\n  1214→    }\n  1215→    stores {\n  1216→        INTEGER id PK\n  1217→        INTEGER organization_id FK\n  1218→        TEXT name\n  1219→        TEXT handle\n  1220→        TEXT status\n  1221→    }\n  1222→    store_domains {\n  1223→        INTEGER id PK\n  1224→        INTEGER store_id FK\n  1225→        TEXT hostname\n  1226→        TEXT type\n  1227→        INTEGER is_primary\n  1228→    }\n  1229→    users {\n  1230→        INTEGER id PK\n  1231→        TEXT email\n  1232→        TEXT password_hash\n  1233→        TEXT name\n  1234→        TEXT status\n  1235→    }\n  1236→    store_users {\n  1237→        INTEGER store_id PK\n  1238→        INTEGER user_id PK\n  1239→        TEXT role\n  1240→    }\n  1241→    store_settings {\n  1242→        INTEGER store_id PK\n  1243→        TEXT settings_json\n  1244→    }\n  1245→```\n  1246→\n  1247→### Epic 2 - Catalog, Collections, Inventory, and Media\n  1248→\n  1249→```mermaid\n  1250→erDiagram\n  1251→    stores ||--o{ products : \"has\"\n  1252→    stores ||--o{ collections : \"has\"\n  1253→    products ||--o{ product_options : \"defines\"\n  1254→    product_options ||--o{ product_option_values : \"contains\"\n  1255→    products ||--o{ product_variants : \"has\"\n  1256→    product_variants }o--o{ product_option_values : \"variant_option_values\"\n  1257→    product_variants ||--|| inventory_items : \"tracked by\"\n  1258→    stores ||--o{ inventory_items : \"has\"\n  1259→    collections }o--o{ products : \"collection_products\"\n  1260→    products ||--o{ product_media : \"has\"\n  1261→\n  1262→    products {\n  1263→        INTEGER id PK\n  1264→        INTEGER store_id FK\n  1265→        TEXT title\n  1266→        TEXT handle\n  1267→        TEXT status\n  1268→    }\n  1269→    product_options {\n  1270→        INTEGER id PK\n  1271→        INTEGER product_id FK\n  1272→        TEXT name\n  1273→        INTEGER position\n  1274→    }\n  1275→    product_option_values {\n  1276→        INTEGER id PK\n  1277→        INTEGER product_option_id FK\n  1278→        TEXT value\n  1279→        INTEGER position\n  1280→    }\n  1281→    product_variants {\n  1282→        INTEGER id PK\n  1283→        INTEGER product_id FK\n  1284→        TEXT sku\n  1285→        INTEGER price_amount\n  1286→        TEXT status\n  1287→    }\n  1288→    inventory_items {\n  1289→        INTEGER id PK\n  1290→        INTEGER store_id FK\n  1291→        INTEGER variant_id FK\n  1292→        INTEGER quantity_on_hand\n  1293→        INTEGER quantity_reserved\n  1294→        TEXT policy\n  1295→    }\n  1296→    collections {\n  1297→        INTEGER id PK\n  1298→        INTEGER store_id FK\n  1299→        TEXT title\n  1300→        TEXT handle\n  1301→        TEXT type\n  1302→        TEXT status\n  1303→    }\n  1304→    product_media {\n  1305→        INTEGER id PK\n  1306→        INTEGER product_id FK\n  1307→        TEXT type\n  1308→        TEXT storage_key\n  1309→        INTEGER position\n  1310→        TEXT status\n  1311→    }\n  1312→```\n  1313→\n  1314→### Epic 3 - Themes, Storefront, and Localization\n  1315→\n  1316→```mermaid\n  1317→erDiagram\n  1318→    stores ||--o{ themes : \"has\"\n  1319→    themes ||--o{ theme_files : \"contains\"\n  1320→    themes ||--|| theme_settings : \"configured by\"\n  1321→    stores ||--o{ pages : \"has\"\n  1322→    stores ||--o{ navigation_menus : \"has\"\n  1323→    navigation_menus ||--o{ navigation_items : \"contains\"\n  1324→\n  1325→    themes {\n  1326→        INTEGER id PK\n  1327→        INTEGER store_id FK\n  1328→        TEXT name\n  1329→        TEXT status\n  1330→    }\n  1331→    theme_files {\n  1332→        INTEGER id PK\n  1333→        INTEGER theme_id FK\n  1334→        TEXT path\n  1335→        TEXT storage_key\n  1336→    }\n  1337→    theme_settings {\n  1338→        INTEGER theme_id PK\n  1339→        TEXT settings_json\n  1340→    }\n  1341→    pages {\n  1342→        INTEGER id PK\n  1343→        INTEGER store_id FK\n  1344→        TEXT title\n  1345→        TEXT handle\n  1346→        TEXT status\n  1347→    }\n  1348→    navigation_menus {\n  1349→        INTEGER id PK\n  1350→        INTEGER store_id FK\n  1351→        TEXT handle\n  1352→        TEXT title\n  1353→    }\n  1354→    navigation_items {\n  1355→        INTEGER id PK\n  1356→        INTEGER menu_id FK\n  1357→        TEXT type\n  1358→        TEXT label\n  1359→        INTEGER position\n  1360→    }\n  1361→```\n  1362→\n  1363→### Epics 5-6 - Cart, Checkout, Orders, and Payments\n  1364→\n  1365→```mermaid\n  1366→erDiagram\n  1367→    stores ||--o{ customers : \"has\"\n  1368→    customers ||--o{ customer_addresses : \"has\"\n  1369→    stores ||--o{ carts : \"has\"\n  1370→    customers ||--o{ carts : \"owns\"\n  1371→    carts ||--o{ cart_lines : \"contains\"\n  1372→    product_variants ||--o{ cart_lines : \"referenced by\"\n  1373→    stores ||--o{ checkouts : \"has\"\n  1374→    carts ||--o{ checkouts : \"converted to\"\n  1375→    stores ||--o{ orders : \"has\"\n  1376→    customers ||--o{ orders : \"placed\"\n  1377→    orders ||--o{ order_lines : \"contains\"\n  1378→    orders ||--o{ payments : \"paid via\"\n  1379→    orders ||--o{ refunds : \"refunded via\"\n  1380→    payments ||--o{ refunds : \"source of\"\n  1381→    orders ||--o{ fulfillments : \"shipped via\"\n  1382→    fulfillments ||--o{ fulfillment_lines : \"contains\"\n  1383→    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n  1384→\n  1385→    customers {\n  1386→        INTEGER id PK\n  1387→        INTEGER store_id FK\n  1388→        TEXT email\n  1389→        TEXT name\n  1390→    }\n  1391→    carts {\n  1392→        INTEGER id PK\n  1393→        INTEGER store_id FK\n  1394→        INTEGER customer_id FK\n  1395→        TEXT status\n  1396→    }\n  1397→    orders {\n  1398→        INTEGER id PK\n  1399→        INTEGER store_id FK\n  1400→        INTEGER customer_id FK\n  1401→        TEXT order_number\n  1402→        TEXT status\n  1403→        TEXT financial_status\n  1404→        TEXT fulfillment_status\n  1405→        INTEGER total_amount\n  1406→    }\n  1407→    payments {\n  1408→        INTEGER id PK\n  1409→        INTEGER order_id FK\n  1410→        TEXT provider\n  1411→        TEXT status\n  1412→        INTEGER amount\n  1413→    }\n  1414→    fulfillments {\n  1415→        INTEGER id PK\n  1416→        INTEGER order_id FK\n  1417→        TEXT status\n  1418→        TEXT tracking_number\n  1419→    }\n  1420→```\n  1421→\n  1422→### Epics 7-8 - Analytics, Apps, and Webhooks\n  1423→\n  1424→```mermaid\n  1425→erDiagram\n  1426→    stores ||--o{ analytics_events : \"records\"\n  1427→    stores ||--o{ analytics_daily : \"aggregates\"\n  1428→    customers ||--o{ analytics_events : \"generates\"\n  1429→    apps ||--o{ app_installations : \"installed via\"\n  1430→    stores ||--o{ app_installations : \"has\"\n  1431→    apps ||--o{ oauth_clients : \"authenticates via\"\n  1432→    app_installations ||--o{ oauth_tokens : \"issues\"\n  1433→    stores ||--o{ webhook_subscriptions : \"registers\"\n  1434→    app_installations ||--o{ webhook_subscriptions : \"owns\"\n  1435→    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n  1436→\n  1437→    analytics_events {\n  1438→        INTEGER id PK\n  1439→        INTEGER store_id FK\n  1440→        TEXT type\n  1441→        TEXT session_id\n  1442→    }\n  1443→    analytics_daily {\n  1444→        INTEGER store_id PK\n  1445→        TEXT date PK\n  1446→        INTEGER orders_count\n  1447→        INTEGER revenue_amount\n  1448→    }\n  1449→    apps {\n  1450→        INTEGER id PK\n  1451→        TEXT name\n  1452→        TEXT status\n  1453→    }\n  1454→    app_installations {\n  1455→        INTEGER id PK\n  1456→        INTEGER store_id FK\n  1457→        INTEGER app_id FK\n  1458→        TEXT status\n  1459→    }\n  1460→    webhook_subscriptions {\n  1461→        INTEGER id PK\n  1462→        INTEGER store_id FK\n  1463→        TEXT event_type\n  1464→        TEXT target_url\n  1465→    }\n  1466→    webhook_deliveries {\n  1467→        INTEGER id PK\n  1468→        INTEGER subscription_id FK\n  1469→        TEXT event_id\n  1470→        TEXT status\n  1471→    }\n  1472→```\n  1473→\n  1474→---\n  1475→\n  1476→## SQLite-Specific Configuration Requirements\n  1477→\n  1478→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  1479→\n  1480→### Required PRAGMA Settings\n  1481→\n  1482→| Pragma | Value | Purpose |\n  1483→|---|---|---|\n  1484→| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n  1485→| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n  1486→| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n  1487→| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n  1488→| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n  1489→\n  1490→### Laravel Configuration Requirements\n  1491→\n  1492→- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n  1493→- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n  1494→- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n  1495→- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n  1496→- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n  1497→- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n  1498→\n  1499→---\n  1500→\n  1501→## Migration Dependency Graph\n  1502→\n  1503→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  1504→\n  1505→```mermaid\n  1506→flowchart TD\n  1507→    subgraph \"Batch 1 - No Dependencies\"\n  1508→        organizations\n  1509→        apps\n  1510→    end\n  1511→\n  1512→    subgraph \"Batch 2 - Depends on Batch 1\"\n  1513→        stores\n  1514→        users\n  1515→    end\n  1516→\n  1517→    subgraph \"Batch 3 - Depends on Batch 2\"\n  1518→        store_domains\n  1519→        store_users\n  1520→        store_settings\n  1521→        customers\n  1522→        themes\n  1523→        pages\n  1524→        navigation_menus\n  1525→        search_settings\n  1526→        shipping_zones\n  1527→        tax_settings\n  1528→        discounts\n  1529→        products\n  1530→        collections\n  1531→        app_installations\n  1532→        oauth_clients\n  1533→    end\n  1534→\n  1535→    subgraph \"Batch 4 - Depends on Batch 3\"\n  1536→        product_options\n  1537→        product_variants\n  1538→        product_media\n  1539→        collection_products\n  1540→        customer_addresses\n  1541→        carts\n  1542→        navigation_items\n  1543→        theme_files\n  1544→        theme_settings\n  1545→        search_queries\n  1546→        shipping_rates\n  1547→        oauth_tokens\n  1548→        webhook_subscriptions\n  1549→    end\n  1550→\n  1551→    subgraph \"Batch 5 - Depends on Batch 4\"\n  1552→        product_option_values\n  1553→        inventory_items\n  1554→        cart_lines\n  1555→        checkouts\n  1556→        orders\n  1557→        analytics_events\n  1558→        analytics_daily\n  1559→        webhook_deliveries\n  1560→    end\n  1561→\n  1562→    subgraph \"Batch 6 - Depends on Batch 5\"\n  1563→        variant_option_values\n  1564→        order_lines\n  1565→        payments\n  1566→        fulfillments\n  1567→    end\n  1568→\n  1569→    subgraph \"Batch 7 - Depends on Batch 6\"\n  1570→        refunds\n  1571→        fulfillment_lines\n  1572→    end\n  1573→\n  1574→    organizations --> stores\n  1575→    apps --> app_installations\n  1576→    apps --> oauth_clients\n  1577→    stores --> store_domains\n  1578→    stores --> store_users\n  1579→    users --> store_users\n  1580→    stores --> store_settings\n  1581→    stores --> customers\n  1582→    stores --> themes\n  1583→    stores --> pages\n  1584→    stores --> navigation_menus\n  1585→    stores --> search_settings\n  1586→    stores --> shipping_zones\n  1587→    stores --> tax_settings\n  1588→    stores --> discounts\n  1589→    stores --> products\n  1590→    stores --> collections\n  1591→    stores --> app_installations\n  1592→    products --> product_options\n  1593→    products --> product_variants\n  1594→    products --> product_media\n  1595→    collections --> collection_products\n  1596→    products --> collection_products\n  1597→    customers --> customer_addresses\n  1598→    stores --> carts\n  1599→    customers --> carts\n  1600→    navigation_menus --> navigation_items\n  1601→    themes --> theme_files\n  1602→    themes --> theme_settings\n  1603→    stores --> search_queries\n  1604→    shipping_zones --> shipping_rates\n  1605→    app_installations --> oauth_tokens\n  1606→    stores --> webhook_subscriptions\n  1607→    app_installations --> webhook_subscriptions\n  1608→    product_options --> product_option_values\n  1609→    stores --> inventory_items\n  1610→    product_variants --> inventory_items\n  1611→    carts --> cart_lines\n  1612→    product_variants --> cart_lines\n  1613→    stores --> checkouts\n  1614→    carts --> checkouts\n  1615→    customers --> checkouts\n  1616→    stores --> orders\n  1617→    customers --> orders\n  1618→    stores --> analytics_events\n  1619→    customers --> analytics_events\n  1620→    stores --> analytics_daily\n  1621→    webhook_subscriptions --> webhook_deliveries\n  1622→    product_variants --> variant_option_values\n  1623→    product_option_values --> variant_option_values\n  1624→    orders --> order_lines\n  1625→    products --> order_lines\n  1626→    product_variants --> order_lines\n  1627→    orders --> payments\n  1628→    orders --> fulfillments\n  1629→    orders --> refunds\n  1630→    payments --> refunds\n  1631→    fulfillments --> fulfillment_lines\n  1632→    order_lines --> fulfillment_lines\n  1633→```\n  1634→\n  1635→### Batch Details\n  1636→\n  1637→- **Batch 1** (no dependencies): `organizations`, `apps`\n  1638→- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n  1639→- **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  1640→- **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  1641→- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n  1642→- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n  1643→- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n  1644→\n  1645→---\n  1646→\n  1647→## Index Strategy Rationale\n  1648→\n  1649→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  1650→\n  1651→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  1652→\n  1653→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  1654→\n  1655→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  1656→\n  1657→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  1658→\n  1659→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  1660→\n  1661→---\n  1662→\n  1663→## Table Count Summary\n  1664→\n  1665→| Epic | Tables | Count |\n  1666→|---|---|---|\n  1667→| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n  1668→| 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  1669→| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n  1670→| 4 - Search | search_settings, search_queries | 2 |\n  1671→| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n  1672→| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n  1673→| 7 - Analytics | analytics_events, analytics_daily | 2 |\n  1674→| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n  1675→| **Total application tables** | | **46** |\n  1676→| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n  1677→| **Grand total** | | **55** |\n  1678→"}]},"uuid":"d8463b07-af52-418c-b44c-97ad9a864796","timestamp":"2026-02-11T15:12:14.570Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","content":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","numLines":1678,"startLine":1,"totalLines":1678}},"sourceToolAssistantUUID":"5f367984-0959-411d-9f2a-ce1bc4d27506"}
{"parentUuid":"d8463b07-af52-418c-b44c-97ad9a864796","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01X1P8bmYqJUJy6jG9YqKwb7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015tNqok6KAFiWYUKNtrityt","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26044,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26044},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vmx1WxappXPmC11uYC","type":"assistant","uuid":"6eb672c6-9a3b-4999-86d3-3f8479603363","timestamp":"2026-02-11T15:12:14.873Z"}
{"parentUuid":"6eb672c6-9a3b-4999-86d3-3f8479603363","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_015tNqok6KAFiWYUKNtrityt","toolUseID":"toolu_015tNqok6KAFiWYUKNtrityt","timestamp":"2026-02-11T15:12:15.081Z","uuid":"eabc761b-4680-4d5e-919b-399df38d246b"}
{"parentUuid":"6eb672c6-9a3b-4999-86d3-3f8479603363","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015tNqok6KAFiWYUKNtrityt","type":"tool_result","content":"     1→# 05 - Business Logic Specification\n     2→\n     3→> Shopify-clone 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_pending` | 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_pending`, `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_pending : Payment initiated\n   423→    payment_pending --> completed : Payment confirmed\n   424→    payment_pending --> 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_pending : 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_pending\n   488→\n   489→**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\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→\n   495→**Actions:**\n   496→1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n   497→2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n   498→3. Set `expires_at` on the checkout to current time + 24 hours.\n   499→4. Transition status to `payment_pending`.\n   500→5. Return the payment session data (redirect URL, client secret, etc.).\n   501→\n   502→#### Transition: payment_pending -> completed\n   503→\n   504→**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n   505→\n   506→**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n   507→\n   508→**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), 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, paymentData):\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. Generate next sequential order number for this store\n   520→\n   521→    3. CREATE order record with:\n   522→       - All checkout data (addresses, email, totals snapshot)\n   523→       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n   524→       - placed_at = now\n   525→\n   526→    4. FOR EACH cart line:\n   527→       CREATE order_line with snapshot data:\n   528→       - title_snapshot = product title + variant option labels\n   529→       - sku_snapshot = variant SKU at time of purchase\n   530→       - unit_price_amount, quantity, total_amount\n   531→       - tax_lines_json, discount_allocations_json\n   532→\n   533→    5. CREATE payment record:\n   534→       - Link to order, provider info, provider_payment_id\n   535→       - status = captured, amount = order total\n   536→       - Encrypt raw provider response for PCI compliance\n   537→\n   538→    6. FOR EACH cart line:\n   539→       CALL InventoryService.commit(variant, quantity)\n   540→\n   541→    7. IF checkout has a discount code:\n   542→       INCREMENT discount's usage_count by 1\n   543→\n   544→    8. SET cart status = 'converted'\n   545→\n   546→    9. SET checkout status = 'completed'\n   547→\n   548→    10. DISPATCH OrderCreated event\n   549→\n   550→    COMMIT TRANSACTION\n   551→    RETURN order\n   552→```\n   553→\n   554→#### Transition: Any active state -> expired\n   555→\n   556→**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n   557→\n   558→**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n   559→\n   560→**Actions:**\n   561→1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n   562→2. Transition status to `expired`.\n   563→3. Dispatch `CheckoutExpired` event.\n   564→\n   565→### 6.3 Events\n   566→\n   567→| Event | Dispatched When |\n   568→|---|---|\n   569→| `CheckoutAddressed` | Address saved on checkout |\n   570→| `CheckoutShippingSelected` | Shipping method selected |\n   571→| `CheckoutCompleted` | Payment confirmed and order created |\n   572→| `CheckoutExpired` | Checkout expired due to inactivity |\n   573→\n   574→---\n   575→\n   576→## 7. Discount Engine\n   577→\n   578→### 7.1 Discount Types\n   579→\n   580→**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n   581→\n   582→| Type | Behavior |\n   583→|---|---|\n   584→| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n   585→| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n   586→\n   587→**Stacking rules:**\n   588→- Only one code discount per checkout.\n   589→- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n   590→\n   591→### Discount Status Lifecycle\n   592→\n   593→| Status | Description | Transition |\n   594→|--------|-------------|------------|\n   595→| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n   596→| `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   597→| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n   598→| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n   599→\n   600→**Allowed transitions:**\n   601→- `draft` -> `active`\n   602→- `active` -> `disabled`\n   603→- `active` -> `expired` (automatic)\n   604→- `disabled` -> `active`\n   605→\n   606→### 7.2 Value Types\n   607→\n   608→**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n   609→\n   610→| Value Type | Calculation | Notes |\n   611→|---|---|---|\n   612→| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n   613→| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n   614→| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n   615→\n   616→### 7.3 Rules\n   617→\n   618→Rules are stored in `discounts.rules_json` with the following structure:\n   619→\n   620→| Rule Key | Type | Description |\n   621→|---|---|---|\n   622→| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n   623→| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n   624→| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n   625→| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n   626→\n   627→**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   628→\n   629→### 7.4 Validation Flow\n   630→\n   631→**Service:** `App\\Services\\DiscountService`\n   632→\n   633→**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n   634→\n   635→```mermaid\n   636→flowchart TD\n   637→    A[Input: discount code + cart] --> B{Code exists in store?\n   638→    Case-insensitive lookup}\n   639→    B -->|No| C[\"Error: discount_not_found\"]\n   640→    B -->|Yes| D{Status = active?}\n   641→    D -->|No| E[\"Error: discount_expired\"]\n   642→    D -->|Yes| F{starts_at <= now?}\n   643→    F -->|No| G[\"Error: discount_not_yet_active\"]\n   644→    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n   645→    H -->|No| I[\"Error: discount_expired\"]\n   646→    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n   647→    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n   648→    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n   649→    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n   650→    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n   651→    N -->|Yes| O[\"Error: discount_not_applicable\"]\n   652→    N -->|No| P[\"Valid - return discount\"]\n   653→```\n   654→\n   655→### 7.5 Error Codes\n   656→\n   657→| Code | Description |\n   658→|---|---|\n   659→| `discount_not_found` | Code does not exist in this store |\n   660→| `discount_expired` | Past `ends_at` date or status is not active |\n   661→| `discount_not_yet_active` | Before `starts_at` date |\n   662→| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n   663→| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n   664→| `discount_not_applicable` | No qualifying products in cart |\n   665→\n   666→### 7.6 Discount Allocation\n   667→\n   668→For `percent` and `fixed` discounts with applicable product restrictions:\n   669→\n   670→```\n   671→PROCEDURE allocateDiscount(discount, cartLines):\n   672→    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n   673→                      OR product belongs to applicable_collection_ids\n   674→    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n   675→\n   676→    IF discount.value_type = 'percent':\n   677→        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n   678→    ELSE IF discount.value_type = 'fixed':\n   679→        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n   680→\n   681→    remainingDiscount = totalDiscount\n   682→\n   683→    FOR EACH qualifyingLine (except last):\n   684→        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n   685→        remainingDiscount -= lineDiscount\n   686→        SET line.line_discount_amount = lineDiscount\n   687→\n   688→    // Last line gets the remainder (largest-remainder method)\n   689→    SET lastLine.line_discount_amount = remainingDiscount\n   690→\n   691→    RECALCULATE line_total_amount for each affected line\n   692→```\n   693→\n   694→**Storage of allocations:**\n   695→- During checkout: update `cart_lines.line_discount_amount`.\n   696→- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n   697→\n   698→### 7.7 Usage Tracking\n   699→\n   700→When an order is successfully created with a discount code:\n   701→- Increment `discounts.usage_count` by 1.\n   702→- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n   703→\n   704→---\n   705→\n   706→## 8. Tax Calculation\n   707→\n   708→### 8.1 Tax Provider Architecture\n   709→\n   710→**Interface:** `App\\Contracts\\TaxProvider`\n   711→\n   712→- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n   713→- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n   714→- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n   715→\n   716→**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n   717→\n   718→**Implementations:**\n   719→- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n   720→- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n   721→\n   722→### 8.2 Manual Tax Mode\n   723→\n   724→**Rate resolution:**\n   725→1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n   726→2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n   727→3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n   728→\n   729→**Calculation per line:**\n   730→\n   731→```\n   732→line_tax = ROUND(line_total_after_discount * rate / 10000)\n   733→```\n   734→\n   735→Round each line's tax independently, then sum for the total tax amount.\n   736→\n   737→### 8.3 Prices Include Tax\n   738→\n   739→Controlled by `tax_settings.prices_include_tax` (boolean).\n   740→\n   741→#### Tax-inclusive pricing (prices_include_tax = true)\n   742→\n   743→- Displayed prices on the storefront are gross (include tax).\n   744→- Subtotal shown to customer is gross.\n   745→- Tax is \"extracted\" from the gross amount:\n   746→\n   747→```\n   748→net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\n   749→tax = gross_amount - net_amount\n   750→```\n   751→\n   752→> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n   753→\n   754→**Example:** Price = 1190 cents, rate = 1900 (19%).\n   755→- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n   756→- tax = 1190 - 1000 = 190\n   757→\n   758→#### Tax-exclusive pricing (prices_include_tax = false)\n   759→\n   760→- Displayed prices are net (excluding tax).\n   761→- Tax added on top:\n   762→\n   763→```\n   764→tax = ROUND(net_amount * rate / 10000)\n   765→total = net_amount + tax\n   766→```\n   767→\n   768→**Example:** Price = 1000 cents, rate = 1900.\n   769→- tax = ROUND(1000 * 1900 / 10000) = 190\n   770→- total = 1190\n   771→\n   772→### 8.4 Stripe Tax Provider (Stub)\n   773→\n   774→**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n   775→\n   776→- Sends line items and shipping address to Stripe Tax API.\n   777→- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n   778→- Fallback behavior (configurable per store in `tax_settings.config_json`):\n   779→\n   780→| Fallback Setting | Behavior |\n   781→|---|---|\n   782→| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n   783→| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n   784→\n   785→### 8.5 Discount-Before-Tax Semantics\n   786→\n   787→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   788→\n   789→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   790→\n   791→**Calculation order within the pricing pipeline:**\n   792→1. Calculate line subtotals.\n   793→2. Apply discount allocations to lines.\n   794→3. Calculate tax on discounted line amounts.\n   795→4. Sum everything for the total.\n   796→\n   797→---\n   798→\n   799→## 9. Shipping Calculation\n   800→\n   801→### 9.1 Zone Matching\n   802→\n   803→**Service:** `App\\Services\\ShippingCalculator`\n   804→\n   805→**Zone matching algorithm:**\n   806→\n   807→```\n   808→PROCEDURE getMatchingZone(address, store):\n   809→    zones = load all shipping zones for store\n   810→\n   811→    bestMatch = null\n   812→    bestSpecificity = -1\n   813→\n   814→    FOR EACH zone IN zones:\n   815→        countryMatch = address.country_code IN zone.countries_json\n   816→        regionMatch = address.province_code IN zone.regions_json\n   817→\n   818→        IF countryMatch AND regionMatch:\n   819→            specificity = 2  // Most specific\n   820→        ELSE IF countryMatch:\n   821→            specificity = 1  // Country-only match\n   822→        ELSE:\n   823→            CONTINUE  // No match\n   824→\n   825→        IF specificity > bestSpecificity:\n   826→            bestMatch = zone\n   827→            bestSpecificity = specificity\n   828→        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n   829→            bestMatch = zone  // Tie-break: lowest ID wins\n   830→\n   831→    RETURN bestMatch  // null if no zone matches\n   832→```\n   833→\n   834→**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n   835→\n   836→### 9.2 Rate Types\n   837→\n   838→For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n   839→\n   840→#### Flat rate\n   841→\n   842→Config structure: `{\"amount\": 500}`\n   843→\n   844→- Cost = fixed amount regardless of cart contents.\n   845→\n   846→#### Weight-based rate\n   847→\n   848→Config structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n   849→\n   850→```\n   851→PROCEDURE calculateWeightRate(config, cart):\n   852→    totalWeight = 0\n   853→    FOR EACH line IN cart.lines:\n   854→        IF line.variant.requires_shipping = true:\n   855→            totalWeight += line.variant.weight_g * line.quantity\n   856→\n   857→    FOR EACH range IN config.ranges:\n   858→        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n   859→            RETURN range.amount\n   860→\n   861→    RETURN null  // Rate not available for this weight\n   862→```\n   863→\n   864→#### Price-based rate\n   865→\n   866→Config structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n   867→\n   868→```\n   869→PROCEDURE calculatePriceRate(config, cartSubtotal):\n   870→    FOR EACH range IN config.ranges:\n   871→        IF range.min_amount <= cartSubtotal:\n   872→            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n   873→                RETURN range.amount\n   874→\n   875→    RETURN null  // Rate not available for this subtotal\n   876→```\n   877→\n   878→A range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n   879→\n   880→#### Carrier-calculated rate (stub)\n   881→\n   882→Config structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n   883→\n   884→- Placeholder for future carrier API integration.\n   885→- Returns a hardcoded rate or error until implemented.\n   886→\n   887→### 9.3 Requires Shipping Check\n   888→\n   889→- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n   890→- shipping_amount = 0 in the pricing result.\n   891→- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n   892→\n   893→**Mixed carts** (some physical, some digital):\n   894→- Shipping is calculated only for physical items' weight.\n   895→- Digital items are excluded from weight calculations.\n   896→\n   897→### 9.4 Free Shipping Discount Override\n   898→\n   899→If a validated discount has `value_type = 'free_shipping'`:\n   900→- Override the calculated shipping amount to 0.\n   901→- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n   902→\n   903→---\n   904→\n   905→## 10. Payment Processing\n   906→\n   907→### 10.1 Payment Provider Architecture\n   908→\n   909→**Interface:** `App\\Contracts\\PaymentProvider`\n   910→\n   911→Must implement:\n   912→- `createSession(checkout)` - returns a PaymentSession value object (redirect URL, client secret, etc.)\n   913→- `handleWebhook(request)` - processes incoming webhook, returns a WebhookResult\n   914→- `refund(payment, amount)` - initiates a refund, returns a RefundResult\n   915→\n   916→**Service:** `App\\Services\\PaymentService` - orchestrates provider selection based on store settings and delegates.\n   917→\n   918→**Provider selection:** Configured per store in `store_settings.settings_json` key `payment_providers` (array of provider strings).\n   919→\n   920→### 10.2 Stripe Integration\n   921→\n   922→**Implementation:** `App\\Services\\Payment\\StripePaymentProvider`\n   923→\n   924→```mermaid\n   925→sequenceDiagram\n   926→    participant Customer\n   927→    participant Storefront\n   928→    participant Backend\n   929→    participant Stripe\n   930→\n   931→    Customer->>Storefront: Click \"Pay with Stripe\"\n   932→    Storefront->>Backend: POST /api/storefront/v1/payments/stripe/checkout-session\n   933→    Backend->>Stripe: Create Checkout Session (line items, success/cancel URLs, metadata with checkout ID)\n   934→    Stripe-->>Backend: Session URL\n   935→    Backend-->>Storefront: Redirect URL\n   936→    Storefront->>Customer: Redirect to Stripe hosted page\n   937→    Customer->>Stripe: Complete payment\n   938→    Stripe->>Customer: Redirect to success_url\n   939→    Stripe->>Backend: Webhook: checkout.session.completed\n   940→    Backend->>Backend: Verify signature, check idempotency\n   941→    Backend->>Backend: Queue ProcessStripeWebhook job\n   942→    Backend->>Backend: Complete checkout, create order (idempotent)\n   943→```\n   944→\n   945→**Webhook endpoint:** `POST /api/webhooks/stripe`\n   946→\n   947→**Webhook handling requirements:**\n   948→1. Verify `Stripe-Signature` header using the webhook secret (stored in config, sourced from environment variable `STRIPE_WEBHOOK_SECRET`).\n   949→2. If signature is invalid: return HTTP 400 immediately. Do not process.\n   950→3. Check idempotency: look up the Stripe event ID in a processed events cache or table. If already processed, return HTTP 200 immediately.\n   951→4. Dispatch a queued job for processing to ensure resilience.\n   952→\n   953→**Handled Stripe events:**\n   954→\n   955→| Event | Action |\n   956→|---|---|\n   957→| `checkout.session.completed` | Complete the checkout, create order |\n   958→| `payment_intent.succeeded` | Update payment status to captured (if not using Checkout Sessions) |\n   959→| `payment_intent.payment_failed` | Mark payment as failed, release inventory, notify customer |\n   960→| `charge.refunded` | Update order financial status, create refund record |\n   961→\n   962→### 10.3 PayPal Integration (Stub)\n   963→\n   964→**Implementation:** `App\\Services\\Payment\\PayPalPaymentProvider`\n   965→\n   966→**Flow:**\n   967→1. Create order via PayPal Orders API (v2).\n   968→2. Return the PayPal approval URL for customer redirect.\n   969→3. On return from PayPal: capture the payment.\n   970→4. Webhook for async confirmation.\n   971→5. Signature verification via PayPal's verification endpoint.\n   972→\n   973→### 10.4 Payment State Machine\n   974→\n   975→**Enum:** `App\\Enums\\PaymentStatus` - values: `requires_action`, `authorized`, `captured`, `failed`, `refunded`\n   976→\n   977→```mermaid\n   978→stateDiagram-v2\n   979→    [*] --> requires_action : Payment initiated\n   980→    requires_action --> authorized : Provider authorizes\n   981→    requires_action --> failed : Provider rejects\n   982→    authorized --> captured : Payment captured\n   983→    captured --> refunded : Full refund processed\n   984→\n   985→    note right of requires_action : Awaiting customer action or provider confirmation\n   986→    note right of authorized : For Stripe Checkout Sessions, authorized + captured happen together\n   987→```\n   988→\n   989→### 10.5 Queued Webhook Processing\n   990→\n   991→**Jobs:**\n   992→- `App\\Jobs\\ProcessStripeWebhook` - handles incoming Stripe webhook events\n   993→- `App\\Jobs\\ProcessPayPalWebhook` - handles incoming PayPal webhook events\n   994→\n   995→Using queued jobs ensures resilience: if processing fails, the job retries automatically per Laravel's retry configuration.\n   996→\n   997→---\n   998→\n   999→## 11. Order Management\n  1000→\n  1001→### 11.1 Order Creation (from Completed Checkout)\n  1002→\n  1003→See Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n  1004→\n  1005→**Key requirements for the order record:**\n  1006→- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n  1007→- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n  1008→- The raw payment provider response must be encrypted before storage (PCI compliance).\n  1009→- The entire operation must be atomic (single database transaction).\n  1010→\n  1011→### 11.2 Order Numbering\n  1012→\n  1013→- Sequential per store, starting at 1001.\n  1014→- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n  1015→- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n  1016→- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n  1017→\n  1018→### 11.3 Order Statuses\n  1019→\n  1020→Three independent status dimensions tracked on each order:\n  1021→\n  1022→**Order status** (`orders.status`):\n  1023→\n  1024→| Status | Description |\n  1025→|---|---|\n  1026→| `pending` | Order created, awaiting processing |\n  1027→| `paid` | Payment confirmed |\n  1028→| `fulfilled` | All items shipped |\n  1029→| `cancelled` | Order cancelled before fulfillment |\n  1030→| `refunded` | Fully refunded |\n  1031→\n  1032→**Financial status** (`orders.financial_status`):\n  1033→\n  1034→| Status | Description |\n  1035→|---|---|\n  1036→| `pending` | Payment not yet confirmed |\n  1037→| `authorized` | Payment authorized but not captured |\n  1038→| `paid` | Payment captured successfully |\n  1039→| `partially_refunded` | Some items or partial amount refunded |\n  1040→| `refunded` | Fully refunded |\n  1041→| `voided` | Authorization voided (no capture occurred) |\n  1042→\n  1043→**Fulfillment status** (`orders.fulfillment_status`):\n  1044→\n  1045→| Status | Description |\n  1046→|---|---|\n  1047→| `unfulfilled` | No items have been shipped |\n  1048→| `partial` | Some items shipped, others pending |\n  1049→| `fulfilled` | All items shipped |\n  1050→\n  1051→### 11.4 Refund Processing\n  1052→\n  1053→**Service:** `App\\Services\\RefundService`\n  1054→\n  1055→**Input (RefundRequest):**\n  1056→\n  1057→| Field | Type | Required | Description |\n  1058→|---|---|---|---|\n  1059→| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n  1060→| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n  1061→| `reason` | string | No | Text reason for the refund. |\n  1062→| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n  1063→\n  1064→**Process:**\n  1065→\n  1066→```\n  1067→PROCEDURE processRefund(order, request):\n  1068→    1. Calculate remaining refundable amount:\n  1069→       refundable = order.total_amount - SUM(existing refunds for this order)\n  1070→\n  1071→    2. IF request.amount is set:\n  1072→           VALIDATE request.amount <= refundable\n  1073→       ELSE IF request.lines is set:\n  1074→           CALCULATE amount from specified lines and quantities\n  1075→       ELSE:\n  1076→           amount = refundable (full refund)\n  1077→\n  1078→    3. CALL PaymentProvider.refund(order's payment, amount)\n  1079→\n  1080→    4. CREATE Refund record with status = 'pending'\n  1081→\n  1082→    5. On provider confirmation:\n  1083→       UPDATE refund status = 'processed'\n  1084→\n  1085→    6. Calculate total refunded for this order:\n  1086→       IF total refunded = order.total_amount:\n  1087→           SET order.financial_status = 'refunded'\n  1088→           SET order.status = 'refunded'\n  1089→       ELSE:\n  1090→           SET order.financial_status = 'partially_refunded'\n  1091→\n  1092→    7. IF request.restock = true:\n  1093→       FOR EACH refunded line and quantity:\n  1094→           CALL InventoryService.restock(variant, quantity)\n  1095→\n  1096→    8. DISPATCH OrderRefunded event\n  1097→```\n  1098→\n  1099→### 11.5 Fulfillment\n  1100→\n  1101→**Service:** `App\\Services\\FulfillmentService`\n  1102→\n  1103→**Creating a fulfillment:**\n  1104→\n  1105→Input:\n  1106→- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n  1107→- `trackingData` - tracking_company, tracking_number, tracking_url\n  1108→\n  1109→**Process:**\n  1110→\n  1111→```\n  1112→PROCEDURE createFulfillment(order, lines, trackingData):\n  1113→    1. FOR EACH line in request:\n  1114→       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n  1115→       unfulfilled = order_line.quantity - fulfilledSoFar\n  1116→       VALIDATE requested quantity <= unfulfilled\n  1117→\n  1118→    2. CREATE Fulfillment record with status = 'pending' and tracking data\n  1119→\n  1120→    3. CREATE fulfillment_lines for each order line and quantity\n  1121→\n  1122→    4. Determine new fulfillment status:\n  1123→       FOR EACH order_line in order:\n  1124→           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n  1125→           IF totalFulfilled < order_line.quantity:\n  1126→               allFulfilled = false\n  1127→\n  1128→       IF allFulfilled:\n  1129→           SET order.fulfillment_status = 'fulfilled'\n  1130→           SET order.status = 'fulfilled'\n  1131→       ELSE:\n  1132→           SET order.fulfillment_status = 'partial'\n  1133→\n  1134→    5. DISPATCH FulfillmentCreated event\n  1135→```\n  1136→\n  1137→**Fulfillment status transitions:**\n  1138→\n  1139→```mermaid\n  1140→stateDiagram-v2\n  1141→    [*] --> pending : Fulfillment created\n  1142→    pending --> shipped : Merchant marks as shipped\n  1143→    shipped --> delivered : Merchant marks as delivered\n  1144→\n  1145→    note right of shipped : Sets shipped_at timestamp, sends tracking email\n  1146→```\n  1147→\n  1148→When transitioning to `shipped`:\n  1149→- Set `shipped_at` timestamp.\n  1150→- Dispatch `FulfillmentShipped` event.\n  1151→- Send email notification to customer with tracking information.\n  1152→\n  1153→### 11.6 Order Export\n  1154→\n  1155→`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n  1156→\n  1157→**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  1158→\n  1159→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  1160→\n  1161→---\n  1162→\n  1163→## 12. Customer Accounts\n  1164→\n  1165→### 12.1 Registration\n  1166→\n  1167→**Service:** `App\\Services\\CustomerService`\n  1168→\n  1169→**Required fields:**\n  1170→\n  1171→| Field | Validation |\n  1172→|---|---|\n  1173→| `name` | Required, string |\n  1174→| `email` | Required, valid email, unique per store |\n  1175→| `password` | Required, minimum 8 characters |\n  1176→| `marketing_opt_in` | Optional, boolean, default false |\n  1177→\n  1178→- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n  1179→- Password hashed using Laravel's Hash facade (bcrypt by default).\n  1180→\n  1181→### 12.2 Authentication\n  1182→\n  1183→**Separate auth guard:** `customer`\n  1184→\n  1185→Requirements:\n  1186→- Configure a dedicated `customer` guard in auth config, using session driver.\n  1187→- Configure a dedicated `customers` provider using the Customer model.\n  1188→- Login: email + password, scoped to the current store.\n  1189→- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n  1190→\n  1191→**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  1192→\n  1193→### 12.3 Data Access\n  1194→\n  1195→- Customers can only view their own orders (scoped by customer_id match).\n  1196→- Customers can only manage their own addresses (scoped by customer_id match).\n  1197→- All customer queries include the StoreScope global scope for tenant isolation.\n  1198→\n  1199→### 12.4 Guest Cart to Customer Linking\n  1200→\n  1201→```\n  1202→PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n  1203→    existingCustomer = find customer by email in store\n  1204→    IF existingCustomer:\n  1205→        LINK order to existingCustomer\n  1206→    ELSE:\n  1207→        CREATE new customer with:\n  1208→            - email = checkoutEmail\n  1209→            - password_hash = null (guest customer)\n  1210→            - store_id = storeId\n  1211→        LINK order to new customer\n  1212→\n  1213→    // Guest customer can later \"claim\" their account\n  1214→    // by setting a password via registration or account-claim flow\n  1215→```\n  1216→\n  1217→---\n  1218→\n  1219→## 13. Webhook Delivery System\n  1220→\n  1221→### 13.1 Subscription Model\n  1222→\n  1223→Stores (or app installations) subscribe to event types via `webhook_subscriptions`.\n  1224→\n  1225→**Supported event types:**\n  1226→\n  1227→| Event Type | Triggered By |\n  1228→|---|---|\n  1229→| `order.created` | OrderCreated event |\n  1230→| `order.paid` | Payment captured successfully |\n  1231→| `order.fulfilled` | All order lines fulfilled |\n  1232→| `order.refunded` | Refund processed |\n  1233→| `product.created` | New product saved |\n  1234→| `product.updated` | Product modified |\n  1235→| `product.deleted` | Product deleted or archived |\n  1236→| `checkout.completed` | Checkout successfully completed |\n  1237→\n  1238→### 13.2 Delivery Process\n  1239→\n  1240→**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n  1241→\n  1242→**Job:** `App\\Jobs\\DeliverWebhook`\n  1243→\n  1244→```mermaid\n  1245→sequenceDiagram\n  1246→    participant DomainEvent\n  1247→    participant Listener\n  1248→    participant Queue\n  1249→    participant Job\n  1250→    participant TargetURL\n  1251→\n  1252→    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n  1253→    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n  1254→    loop For each matching subscription\n  1255→        Listener->>Queue: Dispatch DeliverWebhook job\n  1256→    end\n  1257→    Queue->>Job: Process job\n  1258→    Job->>Job: Serialize event payload as JSON\n  1259→    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n  1260→    Job->>TargetURL: HTTP POST with headers and payload\n  1261→    TargetURL-->>Job: Response\n  1262→    Job->>Job: Record result in webhook_deliveries\n  1263→```\n  1264→\n  1265→**Required HTTP headers on delivery:**\n  1266→\n  1267→| Header | Value |\n  1268→|---|---|\n  1269→| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n  1270→| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n  1271→| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n  1272→| `X-Platform-Timestamp` | Unix timestamp of the event |\n  1273→| `Content-Type` | `application/json` |\n  1274→\n  1275→### 13.3 Retry Logic\n  1276→\n  1277→On non-2xx response or connection timeout:\n  1278→\n  1279→| Attempt | Backoff Delay |\n  1280→|---|---|\n  1281→| 1 (initial) | Immediate |\n  1282→| 2 | 1 minute |\n  1283→| 3 | 5 minutes |\n  1284→| 4 | 30 minutes |\n  1285→| 5 | 2 hours |\n  1286→| 6 (final) | 12 hours |\n  1287→\n  1288→After 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n  1289→\n  1290→Use Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n  1291→\n  1292→### 13.4 Circuit Breaker\n  1293→\n  1294→- Track consecutive failures per subscription (across different events).\n  1295→- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n  1296→- Log a warning and (future) send an admin notification.\n  1297→- Manual re-enable required by the merchant through the admin UI.\n  1298→- On each successful delivery, reset the consecutive failure counter to 0.\n  1299→\n  1300→**Service:** `App\\Services\\WebhookService`\n  1301→\n  1302→---\n  1303→\n  1304→## 14. Analytics\n  1305→\n  1306→### 14.1 Event Ingestion\n  1307→\n  1308→**Endpoint:** `POST /api/storefront/v1/analytics/events`\n  1309→\n  1310→**Request structure:**\n  1311→\n  1312→| Field | Type | Required | Description |\n  1313→|---|---|---|---|\n  1314→Events are submitted in batches:\n  1315→\n  1316→```json\n  1317→{\n  1318→  \"events\": [\n  1319→    {\n  1320→      \"type\": \"page_view\",\n  1321→      \"session_id\": \"...\",\n  1322→      \"client_event_id\": \"...\",\n  1323→      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n  1324→      \"properties\": { ... }\n  1325→    }\n  1326→  ]\n  1327→}\n  1328→```\n  1329→\n  1330→**Event fields:**\n  1331→\n  1332→| Field | Type | Required | Description |\n  1333→|---|---|---|---|\n  1334→| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n  1335→| `session_id` | string | Yes | Client session identifier |\n  1336→| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n  1337→| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n  1338→| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n  1339→\n  1340→**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  1341→\n  1342→**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n  1343→\n  1344→### 14.2 Aggregation\n  1345→\n  1346→**Job:** `App\\Jobs\\AggregateAnalytics`\n  1347→\n  1348→- Schedule: daily at 01:00 UTC\n  1349→- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n  1350→\n  1351→**Aggregated metrics:**\n  1352→\n  1353→| Metric | Calculation |\n  1354→|---|---|\n  1355→| `orders_count` | Count of `checkout_completed` events for the day |\n  1356→| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n  1357→| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n  1358→| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n  1359→| `add_to_cart_count` | Count of `add_to_cart` events |\n  1360→| `checkout_started_count` | Count of `checkout_started` events |\n  1361→\n  1362→---\n  1363→\n  1364→## 15. Media Processing\n  1365→\n  1366→### 15.1 Upload Flow\n  1367→\n  1368→```mermaid\n  1369→flowchart TD\n  1370→    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n  1371→    B --> C[Create product_media record with status = processing]\n  1372→    C --> D[Dispatch ProcessMediaUpload job to queue]\n  1373→    D --> E{Processing succeeds?}\n  1374→    E -->|Yes| F[Store resized versions, set status = ready]\n  1375→    E -->|No| G[Set status = failed, log error, keep original]\n  1376→```\n  1377→\n  1378→### 15.2 Processing\n  1379→\n  1380→**Job:** `App\\Jobs\\ProcessMediaUpload`\n  1381→\n  1382→**Resize targets:**\n  1383→\n  1384→| Key | Max Dimensions | Use Case |\n  1385→|---|---|---|\n  1386→| `thumbnail` | 150x150 px | Admin grid, small previews |\n  1387→| `small` | 300x300 px | Product cards, listings |\n  1388→| `medium` | 600x600 px | Product detail page |\n  1389→| `large` | 1200x1200 px | Full-size product image, zoom |\n  1390→\n  1391→**Processing rules:**\n  1392→- Maintain aspect ratio (contain within target dimensions, no cropping).\n  1393→- Generate WebP version alongside the original format if the server supports it.\n  1394→- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n  1395→- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n  1396→- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n  1397→- Maximum 3 retry attempts before permanent failure.\n  1398→\n  1399→### 15.3 Cleanup\n  1400→\n  1401→When a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n  1402→\n  1403→---\n  1404→\n  1405→## 16. Search (SQLite FTS5)\n  1406→\n  1407→### 16.1 Index Setup\n  1408→\n  1409→Create an FTS5 virtual table named `products_fts` with these columns:\n  1410→\n  1411→| Column | Indexed | Description |\n  1412→|---|---|---|\n  1413→| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n  1414→| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n  1415→| `title` | Yes | Product title, fully searchable |\n  1416→| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n  1417→| `vendor` | Yes | Vendor name |\n  1418→| `product_type` | Yes | Product type |\n  1419→| `tags` | Yes | Space-separated tag values |\n  1420→\n  1421→### 16.2 Index Synchronization\n  1422→\n  1423→**Observer:** `App\\Observers\\ProductObserver`\n  1424→\n  1425→| Model Event | FTS Action |\n  1426→|---|---|\n  1427→| `created` | Insert row into products_fts |\n  1428→| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n  1429→| `deleted` | Delete row from products_fts |\n  1430→\n  1431→**Data preparation before insert:**\n  1432→- Strip HTML tags from `description_html` to get plain text.\n  1433→- Convert JSON tags array to space-separated string.\n  1434→- Use empty string for null vendor and product_type fields.\n  1435→\n  1436→### 16.3 Search Query\n  1437→\n  1438→**Service:** `App\\Services\\SearchService`\n  1439→\n  1440→**Search algorithm:**\n  1441→\n  1442→```\n  1443→PROCEDURE search(store, queryString, limit = 20):\n  1444→    1. Sanitize queryString: escape FTS5 special characters\n  1445→    2. Append '*' to last token for prefix matching (autocomplete)\n  1446→       Example: \"running sh\" becomes \"running sh*\"\n  1447→    3. Execute FTS5 MATCH query:\n  1448→       - Filter by store_id\n  1449→       - Order by FTS5 relevance rank\n  1450→       - Limit results\n  1451→    4. Load matching Product models\n  1452→    5. Exclude products with status != 'active' or published_at IS NULL\n  1453→    6. RETURN ordered collection\n  1454→```\n  1455→\n  1456→### 16.4 Search Logging\n  1457→\n  1458→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  1459→\n  1460→---\n  1461→\n  1462→## 17. Event and Listener Registry\n  1463→\n  1464→Summary of all domain events and their listeners/side effects:\n  1465→\n  1466→| Event | Listeners / Side Effects |\n  1467→|---|---|\n  1468→| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n  1469→| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n  1470→| `CheckoutAddressed` | Recalculate pricing |\n  1471→| `CheckoutShippingSelected` | Recalculate pricing |\n  1472→| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n  1473→| `CheckoutExpired` | Release reserved inventory |\n  1474→| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n  1475→| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n  1476→| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n  1477→| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n  1478→| `FulfillmentCreated` | Update order fulfillment status |\n  1479→| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n  1480→\n  1481→All events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n  1482→\n  1483→---\n  1484→\n  1485→## 18. Scheduled Jobs Registry\n  1486→\n  1487→| Job Class | Schedule | Description |\n  1488→|---|---|---|\n  1489→| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n  1490→| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n  1491→| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n  1492→\n  1493→**Queued jobs (dispatched on demand, not scheduled):**\n  1494→\n  1495→| Job Class | Description |\n  1496→|---|---|\n  1497→| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n  1498→| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n  1499→| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n  1500→| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n  1501→\n  1502→Schedule registration belongs in `routes/console.php`.\n  1503→\n  1504→---\n  1505→\n  1506→## 19. Service Class Registry\n  1507→\n  1508→| Service | Namespace | Responsibility |\n  1509→|---|---|---|\n  1510→| ProductService | `App\\Services` | Product CRUD, status transitions |\n  1511→| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n  1512→| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n  1513→| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n  1514→| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n  1515→| CheckoutService | `App\\Services` | Checkout state machine transitions |\n  1516→| DiscountService | `App\\Services` | Discount validation and application |\n  1517→| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n  1518→| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n  1519→| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n  1520→| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n  1521→| PaymentService | `App\\Services` | Payment provider orchestration |\n  1522→| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n  1523→| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n  1524→| RefundService | `App\\Services` | Refund processing and inventory restock |\n  1525→| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n  1526→| CustomerService | `App\\Services` | Customer registration, account management |\n  1527→| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n  1528→| SearchService | `App\\Services` | FTS5 search queries |\n  1529→| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n  1530→\n  1531→---\n  1532→\n  1533→## 20. Contracts (Interfaces)\n  1534→\n  1535→| Interface | Namespace | Implementing Classes |\n  1536→|---|---|---|\n  1537→| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n  1538→| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n  1539→\n  1540→---\n  1541→\n  1542→## 21. Enum Registry\n  1543→\n  1544→All enums live in `App\\Enums` and are string-backed.\n  1545→\n  1546→| Enum | Values (TitleCase keys, lowercase string values) |\n  1547→|---|---|\n  1548→| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n  1549→| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n  1550→| VariantStatus | Active (`active`), Archived (`archived`) |\n  1551→| DiscountType | Code (`code`), Automatic (`automatic`) |\n  1552→| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n  1553→| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n  1554→| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n  1555→| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n  1556→| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |\n  1557→| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n  1558→| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n  1559→| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n  1560→| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n  1561→| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n  1562→| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n  1563→| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n  1564→\n  1565→---\n  1566→\n  1567→## 22. Value Object Registry\n  1568→\n  1569→All value objects live in `App\\ValueObjects`.\n  1570→\n  1571→| Value Object | Fields | Purpose |\n  1572→|---|---|---|\n  1573→| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n  1574→| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n  1575→| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n  1576→| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n  1577→| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n  1578→| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n  1579→| WebhookResult | success (bool), message (string) | Webhook processing result |\n  1580→| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n  1581→| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n  1582→| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n  1583→"}]},"uuid":"208441ea-f310-4466-be83-ea398e756815","timestamp":"2026-02-11T15:12:15.080Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n\n**Actions:**\n1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n5. Return the payment session data (redirect URL, client secret, etc.).\n\n#### Transition: payment_pending -> completed\n\n**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n\n**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n\n**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. Generate next sequential order number for this store\n\n    3. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    4. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    5. CREATE payment record:\n       - Link to order, provider info, provider_payment_id\n       - status = captured, amount = order total\n       - Encrypt raw provider response for PCI compliance\n\n    6. FOR EACH cart line:\n       CALL InventoryService.commit(variant, quantity)\n\n    7. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    8. SET cart status = 'converted'\n\n    9. SET checkout status = 'completed'\n\n    10. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing\n\n### 10.1 Payment Provider Architecture\n\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `createSession(checkout)` - returns a PaymentSession value object (redirect URL, client secret, etc.)\n- `handleWebhook(request)` - processes incoming webhook, returns a WebhookResult\n- `refund(payment, amount)` - initiates a refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates provider selection based on store settings and delegates.\n\n**Provider selection:** Configured per store in `store_settings.settings_json` key `payment_providers` (array of provider strings).\n\n### 10.2 Stripe Integration\n\n**Implementation:** `App\\Services\\Payment\\StripePaymentProvider`\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant Stripe\n\n    Customer->>Storefront: Click \"Pay with Stripe\"\n    Storefront->>Backend: POST /api/storefront/v1/payments/stripe/checkout-session\n    Backend->>Stripe: Create Checkout Session (line items, success/cancel URLs, metadata with checkout ID)\n    Stripe-->>Backend: Session URL\n    Backend-->>Storefront: Redirect URL\n    Storefront->>Customer: Redirect to Stripe hosted page\n    Customer->>Stripe: Complete payment\n    Stripe->>Customer: Redirect to success_url\n    Stripe->>Backend: Webhook: checkout.session.completed\n    Backend->>Backend: Verify signature, check idempotency\n    Backend->>Backend: Queue ProcessStripeWebhook job\n    Backend->>Backend: Complete checkout, create order (idempotent)\n```\n\n**Webhook endpoint:** `POST /api/webhooks/stripe`\n\n**Webhook handling requirements:**\n1. Verify `Stripe-Signature` header using the webhook secret (stored in config, sourced from environment variable `STRIPE_WEBHOOK_SECRET`).\n2. If signature is invalid: return HTTP 400 immediately. Do not process.\n3. Check idempotency: look up the Stripe event ID in a processed events cache or table. If already processed, return HTTP 200 immediately.\n4. Dispatch a queued job for processing to ensure resilience.\n\n**Handled Stripe events:**\n\n| Event | Action |\n|---|---|\n| `checkout.session.completed` | Complete the checkout, create order |\n| `payment_intent.succeeded` | Update payment status to captured (if not using Checkout Sessions) |\n| `payment_intent.payment_failed` | Mark payment as failed, release inventory, notify customer |\n| `charge.refunded` | Update order financial status, create refund record |\n\n### 10.3 PayPal Integration (Stub)\n\n**Implementation:** `App\\Services\\Payment\\PayPalPaymentProvider`\n\n**Flow:**\n1. Create order via PayPal Orders API (v2).\n2. Return the PayPal approval URL for customer redirect.\n3. On return from PayPal: capture the payment.\n4. Webhook for async confirmation.\n5. Signature verification via PayPal's verification endpoint.\n\n### 10.4 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `requires_action`, `authorized`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> requires_action : Payment initiated\n    requires_action --> authorized : Provider authorizes\n    requires_action --> failed : Provider rejects\n    authorized --> captured : Payment captured\n    captured --> refunded : Full refund processed\n\n    note right of requires_action : Awaiting customer action or provider confirmation\n    note right of authorized : For Stripe Checkout Sessions, authorized + captured happen together\n```\n\n### 10.5 Queued Webhook Processing\n\n**Jobs:**\n- `App\\Jobs\\ProcessStripeWebhook` - handles incoming Stripe webhook events\n- `App\\Jobs\\ProcessPayPalWebhook` - handles incoming PayPal webhook events\n\nUsing queued jobs ensures resilience: if processing fails, the job retries automatically per Laravel's retry configuration.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The raw payment provider response must be encrypted before storage (PCI compliance).\n- The entire operation must be atomic (single database transaction).\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n**Creating a fulfillment:**\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    2. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    3. CREATE fulfillment_lines for each order line and quantity\n\n    4. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    5. DISPATCH FulfillmentCreated event\n```\n\n**Fulfillment status transitions:**\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created\n    pending --> shipped : Merchant marks as shipped\n    shipped --> delivered : Merchant marks as delivered\n\n    note right of shipped : Sets shipped_at timestamp, sends tracking email\n```\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","numLines":1583,"startLine":1,"totalLines":1583}},"sourceToolAssistantUUID":"6eb672c6-9a3b-4999-86d3-3f8479603363"}
{"parentUuid":"208441ea-f310-4466-be83-ea398e756815","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018it6JLCejuqr6GuCRqtoLJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have the full content of both the database schema and business logic specs. Let me also read the other spec files that need changes. I'll read them in parallel.","signature":"Es8CCkYICxgCKkD3PoavqBS7bVoU5BRCi0h2E4tfT6l1dNgFY0o9HD0bHM66dzB5p/8A5ELO00ecnfXhTeIZSbnUaSAF654hoZTsEgw5aP4s08uG2gSJfRYaDHmTlw8ZAXiASY2CkCIwdkPNjIMr3EpaxJ8mxmg6yCcTmNLDlr9ea6JPk3kJ9GULPhQJ7yoNjwepA1zV+FsFKrYBAUXf0nzusNWFcXHsvfuqRVYoAQAqCipN6+txlxBrF15GHR9U+zvwHJvXMrfu6p/f+EqcEEQdrkB0nMLwaPL9cpeLcACmfXB/ti93IoE3enUm5B20L22TbEjmVKWuTSx7Iy1OcW+T1xgXyPar7CViMBF47CoDTnr0VFbBvY+uIGGA88juoTDt/wJOf2pqGs/AT/fV5cq1skzhRukWsyUga0g38fHZ6UitiXN7y61r9rUJxbYnlYQYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":49983,"cache_read_input_tokens":46730,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":49983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VnTPrD88LZWR1Fkaa4","type":"assistant","uuid":"a50b7a88-7eb0-4b74-96cf-f4e1d1934651","timestamp":"2026-02-11T15:12:19.133Z"}
{"parentUuid":"a50b7a88-7eb0-4b74-96cf-f4e1d1934651","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018it6JLCejuqr6GuCRqtoLJ","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me read the remaining spec files that need changes."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":49983,"cache_read_input_tokens":46730,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":49983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VnTPrD88LZWR1Fkaa4","type":"assistant","uuid":"5c7077a6-6be7-4cbb-bdfe-af16de85754f","timestamp":"2026-02-11T15:12:19.629Z"}
{"parentUuid":"5c7077a6-6be7-4cbb-bdfe-af16de85754f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018it6JLCejuqr6GuCRqtoLJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XjqgErXG3Criu9Pa3NA9Hg","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":400,"limit":600}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":49983,"cache_read_input_tokens":46730,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":49983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VnTPrD88LZWR1Fkaa4","type":"assistant","uuid":"f2b4d647-c75e-47a3-be20-8f883d23c204","timestamp":"2026-02-11T15:12:21.153Z"}
{"parentUuid":"f2b4d647-c75e-47a3-be20-8f883d23c204","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01XjqgErXG3Criu9Pa3NA9Hg","toolUseID":"toolu_01XjqgErXG3Criu9Pa3NA9Hg","timestamp":"2026-02-11T15:12:21.157Z","uuid":"47eda291-4140-434e-a995-34441d8f1f3a"}
{"parentUuid":"f2b4d647-c75e-47a3-be20-8f883d23c204","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XjqgErXG3Criu9Pa3NA9Hg","type":"tool_result","content":"   400→\n   401→| Field | Type | Required | Validation |\n   402→|-------|------|----------|------------|\n   403→| `cart_version` | integer | Yes | Must match current cart version. |\n   404→\n   405→**Success response:** `200 OK`\n   406→\n   407→Returns the full cart object.\n   408→\n   409→**Error responses:**\n   410→- `404` - Cart or line not found\n   411→- `409` - Version conflict\n   412→\n   413→---\n   414→\n   415→### 2.2 Checkout Endpoints\n   416→\n   417→#### Checkout Flow Sequence Diagram\n   418→\n   419→```mermaid\n   420→sequenceDiagram\n   421→    participant C as Customer Browser\n   422→    participant API as Storefront API\n   423→    participant Stripe as Stripe API\n   424→    participant WH as Webhook Handler\n   425→\n   426→    C->>API: POST /checkouts (cart_id, email)\n   427→    API-->>C: 201 Checkout (status: started)\n   428→\n   429→    C->>API: PUT /checkouts/{id}/address\n   430→    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n   431→\n   432→    C->>API: PUT /checkouts/{id}/shipping-method\n   433→    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n   434→\n   435→    opt Apply discount\n   436→        C->>API: POST /checkouts/{id}/apply-discount\n   437→        API-->>C: 200 Checkout (totals recalculated)\n   438→    end\n   439→\n   440→    alt Stripe Checkout Session\n   441→        C->>API: POST /payments/stripe/checkout-session\n   442→        API->>Stripe: Create Checkout Session\n   443→        Stripe-->>API: session_url\n   444→        API-->>C: 201 (session_url)\n   445→        C->>Stripe: Redirect to hosted payment page\n   446→        Stripe-->>C: Redirect to success_url\n   447→        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   448→        WH-->>Stripe: 200 OK\n   449→    else Stripe Elements (embedded)\n   450→        C->>API: POST /payments/stripe/payment-intent\n   451→        API->>Stripe: Create PaymentIntent\n   452→        Stripe-->>API: client_secret\n   453→        API-->>C: 201 (client_secret)\n   454→        C->>Stripe: Confirm payment (client-side)\n   455→        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   456→        WH-->>Stripe: 200 OK\n   457→    else PayPal\n   458→        C->>API: POST /payments/paypal/create-order\n   459→        API-->>C: 201 (approval_url)\n   460→        C->>C: Redirect to PayPal approval\n   461→        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n   462→        WH-->>C: 200 OK\n   463→    end\n   464→\n   465→    C->>API: GET /checkouts/{id} (poll or redirect)\n   466→    API-->>C: 200 Checkout (status: completed)\n   467→```\n   468→\n   469→#### POST /api/storefront/v1/checkouts\n   470→\n   471→Create a checkout from a cart.\n   472→\n   473→- **Authentication:** None or session-based\n   474→- **Rate limit:** `checkout` (10/min per session)\n   475→\n   476→**Request body:**\n   477→\n   478→```json\n   479→{\n   480→  \"cart_id\": 42,\n   481→  \"email\": \"customer@example.com\"\n   482→}\n   483→```\n   484→\n   485→| Field | Type | Required | Validation |\n   486→|-------|------|----------|------------|\n   487→| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n   488→| `email` | string | Yes | Valid email address. |\n   489→\n   490→**Success response:** `201 Created`\n   491→\n   492→```json\n   493→{\n   494→  \"id\": 7,\n   495→  \"store_id\": 1,\n   496→  \"cart_id\": 42,\n   497→  \"customer_id\": null,\n   498→  \"status\": \"started\",\n   499→  \"email\": \"customer@example.com\",\n   500→  \"shipping_address_json\": null,\n   501→  \"billing_address_json\": null,\n   502→  \"shipping_method_id\": null,\n   503→  \"discount_code\": null,\n   504→  \"lines\": [\n   505→    {\n   506→      \"variant_id\": 101,\n   507→      \"product_title\": \"Classic T-Shirt\",\n   508→      \"variant_title\": \"Blue / Medium\",\n   509→      \"sku\": \"TSH-BLU-M\",\n   510→      \"quantity\": 2,\n   511→      \"unit_price_amount\": 2500,\n   512→      \"line_total_amount\": 5000\n   513→    }\n   514→  ],\n   515→  \"totals\": {\n   516→    \"subtotal\": 5000,\n   517→    \"discount\": 0,\n   518→    \"shipping\": 0,\n   519→    \"tax\": 0,\n   520→    \"total\": 5000,\n   521→    \"currency\": \"EUR\"\n   522→  },\n   523→  \"available_shipping_methods\": [],\n   524→  \"expires_at\": \"2026-02-12T10:00:00Z\",\n   525→  \"created_at\": \"2026-02-11T10:00:00Z\"\n   526→}\n   527→```\n   528→\n   529→**Error responses:**\n   530→- `404` - Cart not found\n   531→- `422` - Validation errors (empty cart, invalid email)\n   532→\n   533→---\n   534→\n   535→#### GET /api/storefront/v1/checkouts/{checkoutId}\n   536→\n   537→Retrieve the current checkout state and computed totals.\n   538→\n   539→- **Authentication:** None (checkout ID acts as token)\n   540→- **Rate limit:** `checkout` (10/min per session)\n   541→\n   542→**Success response:** `200 OK`\n   543→\n   544→Returns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n   545→\n   546→**Error responses:**\n   547→- `404` - Checkout not found\n   548→- `410` - Checkout expired\n   549→\n   550→---\n   551→\n   552→#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n   553→\n   554→Set the shipping and/or billing address on a checkout.\n   555→\n   556→- **Authentication:** None\n   557→- **Rate limit:** `checkout` (10/min per session)\n   558→\n   559→**Request body:**\n   560→\n   561→```json\n   562→{\n   563→  \"shipping_address\": {\n   564→    \"first_name\": \"Jane\",\n   565→    \"last_name\": \"Doe\",\n   566→    \"address1\": \"123 Main St\",\n   567→    \"address2\": \"Apt 4B\",\n   568→    \"city\": \"Berlin\",\n   569→    \"province\": \"Berlin\",\n   570→    \"province_code\": \"BE\",\n   571→    \"country\": \"DE\",\n   572→    \"country_code\": \"DE\",\n   573→    \"postal_code\": \"10115\",\n   574→    \"phone\": \"+49301234567\"\n   575→  },\n   576→  \"billing_address\": null,\n   577→  \"use_shipping_as_billing\": true\n   578→}\n   579→```\n   580→\n   581→**Shipping address fields:**\n   582→\n   583→| Field | Type | Required | Validation |\n   584→|-------|------|----------|------------|\n   585→| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n   586→| `shipping_address.first_name` | string | Yes | Max: 255 |\n   587→| `shipping_address.last_name` | string | Yes | Max: 255 |\n   588→| `shipping_address.address1` | string | Yes | Max: 500 |\n   589→| `shipping_address.address2` | string | No | Max: 500 |\n   590→| `shipping_address.city` | string | Yes | Max: 255 |\n   591→| `shipping_address.province` | string | No | Max: 255 |\n   592→| `shipping_address.province_code` | string | No | Max: 10 |\n   593→| `shipping_address.country` | string | Yes | Max: 255 |\n   594→| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n   595→| `shipping_address.postal_code` | string | Yes | Max: 20 |\n   596→| `shipping_address.phone` | string | No | Max: 50 |\n   597→| `billing_address` | object | No | Same fields as shipping_address |\n   598→| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n   599→\n   600→**Success response:** `200 OK`\n   601→\n   602→Returns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n   603→\n   604→```json\n   605→{\n   606→  \"id\": 7,\n   607→  \"status\": \"addressed\",\n   608→  \"shipping_address_json\": {\n   609→    \"first_name\": \"Jane\",\n   610→    \"last_name\": \"Doe\",\n   611→    \"address1\": \"123 Main St\",\n   612→    \"address2\": \"Apt 4B\",\n   613→    \"city\": \"Berlin\",\n   614→    \"province\": \"Berlin\",\n   615→    \"province_code\": \"BE\",\n   616→    \"country\": \"DE\",\n   617→    \"country_code\": \"DE\",\n   618→    \"postal_code\": \"10115\",\n   619→    \"phone\": \"+49301234567\"\n   620→  },\n   621→  \"billing_address_json\": {\n   622→    \"first_name\": \"Jane\",\n   623→    \"last_name\": \"Doe\",\n   624→    \"address1\": \"123 Main St\",\n   625→    \"address2\": \"Apt 4B\",\n   626→    \"city\": \"Berlin\",\n   627→    \"province\": \"Berlin\",\n   628→    \"province_code\": \"BE\",\n   629→    \"country\": \"DE\",\n   630→    \"country_code\": \"DE\",\n   631→    \"postal_code\": \"10115\",\n   632→    \"phone\": \"+49301234567\"\n   633→  },\n   634→  \"available_shipping_methods\": [\n   635→    {\n   636→      \"id\": 1,\n   637→      \"name\": \"Standard Shipping\",\n   638→      \"type\": \"flat\",\n   639→      \"price_amount\": 500,\n   640→      \"currency\": \"EUR\",\n   641→      \"estimated_days_min\": 3,\n   642→      \"estimated_days_max\": 5\n   643→    },\n   644→    {\n   645→      \"id\": 2,\n   646→      \"name\": \"Express Shipping\",\n   647→      \"type\": \"flat\",\n   648→      \"price_amount\": 1200,\n   649→      \"currency\": \"EUR\",\n   650→      \"estimated_days_min\": 1,\n   651→      \"estimated_days_max\": 2\n   652→    }\n   653→  ],\n   654→  \"totals\": {\n   655→    \"subtotal\": 5000,\n   656→    \"discount\": 0,\n   657→    \"shipping\": 0,\n   658→    \"tax\": 0,\n   659→    \"total\": 5000,\n   660→    \"currency\": \"EUR\"\n   661→  }\n   662→}\n   663→```\n   664→\n   665→**Error responses:**\n   666→- `404` - Checkout not found\n   667→- `410` - Checkout expired\n   668→- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n   669→\n   670→---\n   671→\n   672→#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n   673→\n   674→Select a shipping method for the checkout.\n   675→\n   676→- **Authentication:** None\n   677→- **Rate limit:** `checkout` (10/min per session)\n   678→\n   679→**Request body:**\n   680→\n   681→```json\n   682→{\n   683→  \"shipping_method_id\": 1\n   684→}\n   685→```\n   686→\n   687→| Field | Type | Required | Validation |\n   688→|-------|------|----------|------------|\n   689→| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n   690→\n   691→**Success response:** `200 OK`\n   692→\n   693→Returns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n   694→\n   695→```json\n   696→{\n   697→  \"id\": 7,\n   698→  \"status\": \"shipping_selected\",\n   699→  \"shipping_method_id\": 1,\n   700→  \"totals\": {\n   701→    \"subtotal\": 5000,\n   702→    \"discount\": 0,\n   703→    \"shipping\": 500,\n   704→    \"tax\": 1045,\n   705→    \"total\": 6545,\n   706→    \"currency\": \"EUR\"\n   707→  },\n   708→  \"tax_provider_snapshot_json\": {\n   709→    \"provider\": \"manual\",\n   710→    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n   711→    \"lines\": [\n   712→      {\n   713→        \"variant_id\": 101,\n   714→        \"tax_amount\": 950,\n   715→        \"rate\": 1900,\n   716→        \"jurisdiction\": \"DE\"\n   717→      }\n   718→    ],\n   719→    \"shipping_tax_amount\": 95,\n   720→    \"shipping_tax_rate\": 1900\n   721→  }\n   722→}\n   723→```\n   724→\n   725→**Error responses:**\n   726→- `404` - Checkout not found\n   727→- `410` - Checkout expired\n   728→- `422` - Invalid shipping method (not available for this address/checkout)\n   729→\n   730→---\n   731→\n   732→#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n   733→\n   734→Apply a discount code to the checkout.\n   735→\n   736→- **Authentication:** None\n   737→- **Rate limit:** `checkout` (10/min per session)\n   738→\n   739→**Request body:**\n   740→\n   741→```json\n   742→{\n   743→  \"code\": \"WELCOME10\"\n   744→}\n   745→```\n   746→\n   747→| Field | Type | Required | Validation |\n   748→|-------|------|----------|------------|\n   749→| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n   750→\n   751→**Success response:** `200 OK`\n   752→\n   753→```json\n   754→{\n   755→  \"id\": 7,\n   756→  \"status\": \"shipping_selected\",\n   757→  \"discount_code\": \"WELCOME10\",\n   758→  \"totals\": {\n   759→    \"subtotal\": 5000,\n   760→    \"discount\": 500,\n   761→    \"shipping\": 500,\n   762→    \"tax\": 950,\n   763→    \"total\": 5950,\n   764→    \"currency\": \"EUR\"\n   765→  },\n   766→  \"applied_discounts\": [\n   767→    {\n   768→      \"code\": \"WELCOME10\",\n   769→      \"type\": \"percent\",\n   770→      \"value_amount\": 10,\n   771→      \"applied_amount\": 500,\n   772→      \"description\": \"10% off your first order\"\n   773→    }\n   774→  ]\n   775→}\n   776→```\n   777→\n   778→**Error responses:**\n   779→- `400` - Discount code expired (error code: `discount_expired`)\n   780→- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n   781→- `404` - Checkout not found\n   782→- `410` - Checkout expired\n   783→- `422` - Invalid code (not found, does not apply to these items)\n   784→\n   785→```json\n   786→{\n   787→  \"message\": \"This discount code has expired.\",\n   788→  \"error_code\": \"discount_expired\"\n   789→}\n   790→```\n   791→\n   792→---\n   793→\n   794→#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n   795→\n   796→Remove the applied discount code from the checkout.\n   797→\n   798→- **Authentication:** None\n   799→- **Rate limit:** `checkout` (10/min per session)\n   800→\n   801→**Success response:** `200 OK`\n   802→\n   803→Returns the full checkout object with discount removed and totals recalculated.\n   804→\n   805→**Error responses:**\n   806→- `404` - Checkout not found or no discount applied\n   807→- `410` - Checkout expired\n   808→\n   809→---\n   810→\n   811→### 2.3 Payment Endpoints\n   812→\n   813→#### Payment Flow Sequence Diagram (Stripe)\n   814→\n   815→```mermaid\n   816→sequenceDiagram\n   817→    participant C as Customer Browser\n   818→    participant API as Storefront API\n   819→    participant Stripe as Stripe\n   820→    participant WH as Webhook Receiver\n   821→    participant Order as Order System\n   822→\n   823→    C->>API: POST /payments/stripe/payment-intent\n   824→    API->>Stripe: Create PaymentIntent (amount, currency, metadata)\n   825→    Stripe-->>API: PaymentIntent (client_secret)\n   826→    API-->>C: 201 {client_secret, payment_intent_id, amount, currency}\n   827→\n   828→    C->>Stripe: confirmCardPayment(client_secret)\n   829→    Stripe-->>C: Payment result\n   830→\n   831→    Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n   832→    WH->>WH: Verify Stripe-Signature header\n   833→    WH->>WH: Check idempotency (event.id)\n   834→    WH->>Order: Create order, decrement inventory, send confirmation\n   835→    WH-->>Stripe: 200 {received: true}\n   836→```\n   837→\n   838→#### POST /api/storefront/v1/payments/stripe/checkout-session\n   839→\n   840→Create a Stripe-hosted Checkout Session for the checkout. Redirects the user to Stripe's hosted payment page.\n   841→\n   842→- **Authentication:** None\n   843→- **Rate limit:** `checkout` (10/min per session)\n   844→\n   845→**Request body:**\n   846→\n   847→```json\n   848→{\n   849→  \"checkout_id\": 7,\n   850→  \"success_url\": \"https://acme.test/checkout/7/confirmation\",\n   851→  \"cancel_url\": \"https://acme.test/checkout/7\"\n   852→}\n   853→```\n   854→\n   855→| Field | Type | Required | Validation |\n   856→|-------|------|----------|------------|\n   857→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   858→| `success_url` | string | Yes | Valid URL. |\n   859→| `cancel_url` | string | Yes | Valid URL. |\n   860→\n   861→**Success response:** `201 Created`\n   862→\n   863→```json\n   864→{\n   865→  \"session_id\": \"cs_live_abc123def456\",\n   866→  \"session_url\": \"https://checkout.stripe.com/c/pay/cs_live_abc123def456\",\n   867→  \"expires_at\": \"2026-02-11T10:30:00Z\"\n   868→}\n   869→```\n   870→\n   871→**Error responses:**\n   872→- `404` - Checkout not found\n   873→- `409` - Checkout not in a valid state for payment\n   874→- `422` - Validation errors\n   875→- `502` - Stripe API error\n   876→\n   877→---\n   878→\n   879→#### POST /api/storefront/v1/payments/stripe/payment-intent\n   880→\n   881→Create a Stripe PaymentIntent for embedded payment form (Stripe Elements).\n   882→\n   883→- **Authentication:** None\n   884→- **Rate limit:** `checkout` (10/min per session)\n   885→\n   886→**Request body:**\n   887→\n   888→```json\n   889→{\n   890→  \"checkout_id\": 7\n   891→}\n   892→```\n   893→\n   894→| Field | Type | Required | Validation |\n   895→|-------|------|----------|------------|\n   896→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   897→\n   898→**Success response:** `201 Created`\n   899→\n   900→```json\n   901→{\n   902→  \"client_secret\": \"pi_abc123_secret_def456\",\n   903→  \"payment_intent_id\": \"pi_abc123\",\n   904→  \"amount\": 6545,\n   905→  \"currency\": \"eur\"\n   906→}\n   907→```\n   908→\n   909→**Error responses:**\n   910→- `404` - Checkout not found\n   911→- `409` - Checkout not in a valid state for payment\n   912→- `502` - Stripe API error\n   913→\n   914→---\n   915→\n   916→#### POST /api/storefront/v1/payments/paypal/create-order\n   917→\n   918→Create a PayPal order for the checkout.\n   919→\n   920→- **Authentication:** None\n   921→- **Rate limit:** `checkout` (10/min per session)\n   922→\n   923→**Request body:**\n   924→\n   925→```json\n   926→{\n   927→  \"checkout_id\": 7,\n   928→  \"return_url\": \"https://acme.test/checkout/7/confirmation\",\n   929→  \"cancel_url\": \"https://acme.test/checkout/7\"\n   930→}\n   931→```\n   932→\n   933→| Field | Type | Required | Validation |\n   934→|-------|------|----------|------------|\n   935→| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n   936→| `return_url` | string | Yes | Valid URL. |\n   937→| `cancel_url` | string | Yes | Valid URL. |\n   938→\n   939→**Success response:** `201 Created`\n   940→\n   941→```json\n   942→{\n   943→  \"paypal_order_id\": \"5O190127TN364715T\",\n   944→  \"approval_url\": \"https://www.paypal.com/checkoutnow?token=5O190127TN364715T\",\n   945→  \"status\": \"CREATED\"\n   946→}\n   947→```\n   948→\n   949→**Error responses:**\n   950→- `404` - Checkout not found\n   951→- `409` - Checkout not in a valid state for payment\n   952→- `422` - Validation errors\n   953→- `502` - PayPal API error\n   954→\n   955→---\n   956→\n   957→### 2.4 Order Endpoints\n   958→\n   959→#### GET /api/storefront/v1/orders/{orderNumber}\n   960→\n   961→Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n   962→\n   963→- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n   964→- **Rate limit:** `api.storefront` (120/min per IP)\n   965→\n   966→**Query parameters:**\n   967→\n   968→| Parameter | Type | Required | Description |\n   969→|-----------|------|----------|-------------|\n   970→| `token` | string | Yes | HMAC-signed access token for this order |\n   971→\n   972→**Success response:** `200 OK`\n   973→\n   974→```json\n   975→{\n   976→  \"order_number\": \"#1001\",\n   977→  \"status\": \"paid\",\n   978→  \"financial_status\": \"paid\",\n   979→  \"fulfillment_status\": \"unfulfilled\",\n   980→  \"email\": \"customer@example.com\",\n   981→  \"currency\": \"EUR\",\n   982→  \"placed_at\": \"2026-02-11T10:10:00Z\",\n   983→  \"lines\": [\n   984→    {\n   985→      \"title_snapshot\": \"Classic T-Shirt\",\n   986→      \"variant_title\": \"Blue / Medium\",\n   987→      \"sku_snapshot\": \"TSH-BLU-M\",\n   988→      \"quantity\": 2,\n   989→      \"unit_price_amount\": 2500,\n   990→      \"total_amount\": 5000\n   991→    }\n   992→  ],\n   993→  \"totals\": {\n   994→    \"subtotal_amount\": 5000,\n   995→    \"discount_amount\": 500,\n   996→    \"shipping_amount\": 500,\n   997→    \"tax_amount\": 950,\n   998→    \"total_amount\": 5950\n   999→  },"}]},"uuid":"87435e52-bda6-4b41-9e02-6cf0083bf1d8","timestamp":"2026-02-11T15:12:21.157Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe API\n    participant WH as Webhook Handler\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    alt Stripe Checkout Session\n        C->>API: POST /payments/stripe/checkout-session\n        API->>Stripe: Create Checkout Session\n        Stripe-->>API: session_url\n        API-->>C: 201 (session_url)\n        C->>Stripe: Redirect to hosted payment page\n        Stripe-->>C: Redirect to success_url\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else Stripe Elements (embedded)\n        C->>API: POST /payments/stripe/payment-intent\n        API->>Stripe: Create PaymentIntent\n        Stripe-->>API: client_secret\n        API-->>C: 201 (client_secret)\n        C->>Stripe: Confirm payment (client-side)\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else PayPal\n        C->>API: POST /payments/paypal/create-order\n        API-->>C: 201 (approval_url)\n        C->>C: Redirect to PayPal approval\n        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n        WH-->>C: 200 OK\n    end\n\n    C->>API: GET /checkouts/{id} (poll or redirect)\n    API-->>C: 200 Checkout (status: completed)\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoints\n\n#### Payment Flow Sequence Diagram (Stripe)\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe\n    participant WH as Webhook Receiver\n    participant Order as Order System\n\n    C->>API: POST /payments/stripe/payment-intent\n    API->>Stripe: Create PaymentIntent (amount, currency, metadata)\n    Stripe-->>API: PaymentIntent (client_secret)\n    API-->>C: 201 {client_secret, payment_intent_id, amount, currency}\n\n    C->>Stripe: confirmCardPayment(client_secret)\n    Stripe-->>C: Payment result\n\n    Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n    WH->>WH: Verify Stripe-Signature header\n    WH->>WH: Check idempotency (event.id)\n    WH->>Order: Create order, decrement inventory, send confirmation\n    WH-->>Stripe: 200 {received: true}\n```\n\n#### POST /api/storefront/v1/payments/stripe/checkout-session\n\nCreate a Stripe-hosted Checkout Session for the checkout. Redirects the user to Stripe's hosted payment page.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7,\n  \"success_url\": \"https://acme.test/checkout/7/confirmation\",\n  \"cancel_url\": \"https://acme.test/checkout/7\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n| `success_url` | string | Yes | Valid URL. |\n| `cancel_url` | string | Yes | Valid URL. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"session_id\": \"cs_live_abc123def456\",\n  \"session_url\": \"https://checkout.stripe.com/c/pay/cs_live_abc123def456\",\n  \"expires_at\": \"2026-02-11T10:30:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `422` - Validation errors\n- `502` - Stripe API error\n\n---\n\n#### POST /api/storefront/v1/payments/stripe/payment-intent\n\nCreate a Stripe PaymentIntent for embedded payment form (Stripe Elements).\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"client_secret\": \"pi_abc123_secret_def456\",\n  \"payment_intent_id\": \"pi_abc123\",\n  \"amount\": 6545,\n  \"currency\": \"eur\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `502` - Stripe API error\n\n---\n\n#### POST /api/storefront/v1/payments/paypal/create-order\n\nCreate a PayPal order for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7,\n  \"return_url\": \"https://acme.test/checkout/7/confirmation\",\n  \"cancel_url\": \"https://acme.test/checkout/7\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n| `return_url` | string | Yes | Valid URL. |\n| `cancel_url` | string | Yes | Valid URL. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"paypal_order_id\": \"5O190127TN364715T\",\n  \"approval_url\": \"https://www.paypal.com/checkoutnow?token=5O190127TN364715T\",\n  \"status\": \"CREATED\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `422` - Validation errors\n- `502` - PayPal API error\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },","numLines":600,"startLine":400,"totalLines":3383}},"sourceToolAssistantUUID":"f2b4d647-c75e-47a3-be20-8f883d23c204"}
{"parentUuid":"87435e52-bda6-4b41-9e02-6cf0083bf1d8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018it6JLCejuqr6GuCRqtoLJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QSqZgkoF5vH2W9bMQ3MYkc","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","offset":900,"limit":250}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":49983,"cache_read_input_tokens":46730,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":49983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VnTPrD88LZWR1Fkaa4","type":"assistant","uuid":"9f847ec3-1161-4650-84c4-bd98ebd30983","timestamp":"2026-02-11T15:12:22.065Z"}
{"parentUuid":"9f847ec3-1161-4650-84c4-bd98ebd30983","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01QSqZgkoF5vH2W9bMQ3MYkc","toolUseID":"toolu_01QSqZgkoF5vH2W9bMQ3MYkc","timestamp":"2026-02-11T15:12:22.068Z","uuid":"ef04ebf6-a587-4fc4-8291-26c538155c1b"}
{"parentUuid":"9f847ec3-1161-4650-84c4-bd98ebd30983","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QSqZgkoF5vH2W9bMQ3MYkc","type":"tool_result","content":"   900→|----------|----------------|-----------|-----------|---------|--------|\n   901→| #1001    | Jan 15, 2:30 PM| John Doe  | Paid      | Unful.  | $131.36|\n   902→| #1000    | Jan 14, 9:15 AM| Guest     | Pending   | Unful.  | $45.00 |\n   903→+------------------------------------------------------------------+\n   904→| < 1 2 3 >                                                        |\n   905→+------------------------------------------------------------------+\n   906→```\n   907→\n   908→### Filter Tabs\n   909→\n   910→A horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.\n   911→\n   912→### Table Column Definitions\n   913→\n   914→| Column | Content |\n   915→|--------|---------|\n   916→| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n   917→| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n   918→| Customer | Customer name or \"Guest\" |\n   919→| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n   920→| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n   921→| Total | Formatted currency amount |\n   922→\n   923→Pagination below the table.\n   924→\n   925→### Flux Component Mapping\n   926→\n   927→| UI Element | Flux Component |\n   928→|-----------|----------------|\n   929→| Page title | `heading` |\n   930→| Search field | `input` |\n   931→| Status badges | `badge` |\n   932→| Cell text | `text` |\n   933→\n   934→---\n   935→\n   936→## 8. Order Detail\n   937→\n   938→**Route:** `GET /admin/orders/{order}`\n   939→\n   940→**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n   941→\n   942→**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n   943→\n   944→### Component Requirements\n   945→\n   946→| Property | Type | Purpose |\n   947→|----------|------|---------|\n   948→| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n   949→| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n   950→| `trackingCompany` | `string` | Tracking company for fulfillment |\n   951→| `trackingNumber` | `string` | Tracking number |\n   952→| `trackingUrl` | `string` | Tracking URL |\n   953→| `refundAmount` | `?int` | Custom refund amount |\n   954→| `refundReason` | `string` | Refund reason text |\n   955→| `refundLines` | `array` | Lines selected for refund |\n   956→\n   957→| Action | Behavior |\n   958→|--------|----------|\n   959→| `mount(Order $order)` | Loads order with all relationships |\n   960→| `createFulfillment()` | Creates a fulfillment record for selected lines |\n   961→| `createRefund()` | Creates a refund for selected lines or custom amount |\n   962→| `openFulfillmentModal()` | Opens the fulfillment modal |\n   963→| `openRefundModal()` | Opens the refund modal |\n   964→\n   965→### Two-Column Layout Wireframe\n   966→\n   967→```\n   968→+----------------------------------------------+--------------------+\n   969→|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n   970→+----------------------------------------------+--------------------+\n   971→| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n   972→| Jan 15, 2024 2:30 PM                        | John Doe           |\n   973→|                                              | john@example.com   |\n   974→| [Create fulfillment]  [Refund]               | > View customer    |\n   975→|                                              |--------------------|\n   976→| TIMELINE                                     | SHIPPING ADDRESS   |\n   977→| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n   978→| |                                            | Springfield, IL    |\n   979→| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n   980→| |                                            | United States      |\n   981→| o  (more events...)                          |--------------------|\n   982→|                                              | BILLING ADDRESS    |\n   983→| ORDER LINES                                  | 123 Main St        |\n   984→| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n   985→| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n   986→| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n   987→|                                              |                    |\n   988→|                    Subtotal     $140.00       |                    |\n   989→|                    Discount     -$12.00       |                    |\n   990→|                    Shipping      $5.00        |                    |\n   991→|                    Tax          $18.36        |                    |\n   992→|                    Total       $151.36        |                    |\n   993→|                                              |                    |\n   994→| PAYMENT DETAILS                              |                    |\n   995→| Provider: Stripe  [Paid]                     |                    |\n   996→| Amount: $151.36                              |                    |\n   997→| Payment ID: pi_xxx...                        |                    |\n   998→+----------------------------------------------+--------------------+\n   999→```\n  1000→\n  1001→### Left Column Sections\n  1002→\n  1003→#### Order Heading\n  1004→- Order number as a large heading\n  1005→- Financial status badge (color mapped: paid=green, default=zinc)\n  1006→- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n  1007→- Placed-at date as muted text\n  1008→\n  1009→#### Action Buttons\n  1010→Shown below the heading:\n  1011→- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n  1012→- \"Refund\" button (ghost variant, shown when order is paid)\n  1013→\n  1014→#### Timeline\n  1015→A vertical timeline with left border line and dot markers:\n  1016→- Each event: title text (bold) and timestamp text (small, muted)\n  1017→- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n  1018→\n  1019→#### Order Lines Table\n  1020→\n  1021→| Column | Content |\n  1022→|--------|---------|\n  1023→| Image | 40x40 product thumbnail |\n  1024→| Product | Title and SKU |\n  1025→| Quantity | Unit count |\n  1026→| Unit Price | Formatted amount |\n  1027→| Total | Line total formatted |\n  1028→\n  1029→#### Order Summary\n  1030→A right-aligned summary block below the lines table:\n  1031→- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n  1032→- Each row is a flex container with label left and amount right\n  1033→\n  1034→#### Payment Details\n  1035→A card showing:\n  1036→- Provider (Stripe/PayPal) as text\n  1037→- Status as Flux badge\n  1038→- Amount\n  1039→- Provider payment ID (linked if applicable)\n  1040→\n  1041→### Right Column Sections\n  1042→\n  1043→#### Customer Card\n  1044→- Heading \"Customer\" (size md)\n  1045→- Separator\n  1046→- Customer name (or \"Guest\")\n  1047→- Customer email (muted text)\n  1048→- \"View customer\" link (shown only if customer exists, links to customer detail page)\n  1049→\n  1050→#### Shipping Address Card\n  1051→- Heading \"Shipping address\" (size md)\n  1052→- Separator\n  1053→- Full address rendered from shipping_address_json: line1, city/state/zip, country\n  1054→\n  1055→#### Billing Address Card\n  1056→Same pattern as shipping address, using billing_address_json.\n  1057→\n  1058→### Fulfillment Modal\n  1059→\n  1060→| Property | Value |\n  1061→|----------|-------|\n  1062→| Modal name | `create-fulfillment` |\n  1063→| Max width | lg |\n  1064→| Heading | \"Create fulfillment\" |\n  1065→\n  1066→**Content:**\n  1067→- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n  1068→- Separator\n  1069→- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n  1070→- Tracking number input field\n  1071→- Tracking URL input field (type: url)\n  1072→- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n  1073→\n  1074→### Refund Modal\n  1075→\n  1076→| Property | Value |\n  1077→|----------|-------|\n  1078→| Modal name | `create-refund` |\n  1079→| Max width | lg |\n  1080→| Heading | \"Refund order\" |\n  1081→\n  1082→**Content:**\n  1083→- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n  1084→- Separator\n  1085→- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n  1086→- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n  1087→- Cancel button (ghost) and \"Create refund\" button (danger)\n  1088→\n  1089→### Flux Component Mapping\n  1090→\n  1091→| UI Element | Flux Component |\n  1092→|-----------|----------------|\n  1093→| Order number heading | `heading` |\n  1094→| Status badges | `badge` |\n  1095→| Action buttons | `button` |\n  1096→| Section headings | `heading` (size md) |\n  1097→| Section dividers | `separator` |\n  1098→| Text content | `text` |\n  1099→| Both modals | `modal` |\n  1100→| Modal form fields | `field`, `input`, `textarea` |\n  1101→| Line checkboxes | `checkbox` |\n  1102→| Modal buttons | `button` |\n  1103→| Icons | `icon` |\n  1104→| Callout messages | `callout` |\n  1105→\n  1106→---\n  1107→\n  1108→## 9. Customers List and Detail\n  1109→\n  1110→### 9.1 Customers List\n  1111→\n  1112→**Route:** `GET /admin/customers`\n  1113→\n  1114→**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n  1115→\n  1116→**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`\n  1117→\n  1118→#### Component Requirements\n  1119→\n  1120→| Property | Type | Purpose |\n  1121→|----------|------|---------|\n  1122→| `search` | `string` | Search by name or email |\n  1123→\n  1124→| Computed Property | Purpose |\n  1125→|-------------------|---------|\n  1126→| `customers` | Paginated, searched customers with `withCount('orders')` and `withSum('orders', 'total_amount')` |\n  1127→\n  1128→#### Layout\n  1129→\n  1130→- Heading \"Customers\"\n  1131→- Search input\n  1132→- Table columns: Name, Email, Orders count, Total spent (formatted), Created date\n  1133→- Each row links to customer detail\n  1134→\n  1135→### 9.2 Customer Detail\n  1136→\n  1137→**Route:** `GET /admin/customers/{customer}`\n  1138→\n  1139→**Livewire component:** `App\\Livewire\\Admin\\Customers\\Show`\n  1140→\n  1141→**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`\n  1142→\n  1143→#### Component Requirements\n  1144→\n  1145→| Property | Type | Purpose |\n  1146→|----------|------|---------|\n  1147→| `customer` | `Customer` | The customer model |\n  1148→| `editingAddress` | `?CustomerAddress` | Address currently being edited |\n  1149→| `addressLabel` | `string` | Label for address form |"}]},"uuid":"ea692fdb-4612-4826-8e60-9580b9784b38","timestamp":"2026-02-11T15:12:22.068Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","content":"|----------|----------------|-----------|-----------|---------|--------|\n| #1001    | Jan 15, 2:30 PM| John Doe  | Paid      | Unful.  | $131.36|\n| #1000    | Jan 14, 9:15 AM| Guest     | Pending   | Unful.  | $45.00 |\n+------------------------------------------------------------------+\n| < 1 2 3 >                                                        |\n+------------------------------------------------------------------+\n```\n\n### Filter Tabs\n\nA horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.\n\n### Table Column Definitions\n\n| Column | Content |\n|--------|---------|\n| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n| Customer | Customer name or \"Guest\" |\n| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n| Total | Formatted currency amount |\n\nPagination below the table.\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Search field | `input` |\n| Status badges | `badge` |\n| Cell text | `text` |\n\n---\n\n## 8. Order Detail\n\n**Route:** `GET /admin/orders/{order}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n\n**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `createFulfillment()` | Creates a fulfillment record for selected lines |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |\n\n### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n| Jan 15, 2024 2:30 PM                        | John Doe           |\n|                                              | john@example.com   |\n| [Create fulfillment]  [Refund]               | > View customer    |\n|                                              |--------------------|\n| TIMELINE                                     | SHIPPING ADDRESS   |\n| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n| |                                            | Springfield, IL    |\n| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n| |                                            | United States      |\n| o  (more events...)                          |--------------------|\n|                                              | BILLING ADDRESS    |\n| ORDER LINES                                  | 123 Main St        |\n| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n|                                              |                    |\n|                    Subtotal     $140.00       |                    |\n|                    Discount     -$12.00       |                    |\n|                    Shipping      $5.00        |                    |\n|                    Tax          $18.36        |                    |\n|                    Total       $151.36        |                    |\n|                                              |                    |\n| PAYMENT DETAILS                              |                    |\n| Provider: Stripe  [Paid]                     |                    |\n| Amount: $151.36                              |                    |\n| Payment ID: pi_xxx...                        |                    |\n+----------------------------------------------+--------------------+\n```\n\n### Left Column Sections\n\n#### Order Heading\n- Order number as a large heading\n- Financial status badge (color mapped: paid=green, default=zinc)\n- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n- Placed-at date as muted text\n\n#### Action Buttons\nShown below the heading:\n- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n- \"Refund\" button (ghost variant, shown when order is paid)\n\n#### Timeline\nA vertical timeline with left border line and dot markers:\n- Each event: title text (bold) and timestamp text (small, muted)\n- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n\n#### Order Lines Table\n\n| Column | Content |\n|--------|---------|\n| Image | 40x40 product thumbnail |\n| Product | Title and SKU |\n| Quantity | Unit count |\n| Unit Price | Formatted amount |\n| Total | Line total formatted |\n\n#### Order Summary\nA right-aligned summary block below the lines table:\n- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n- Each row is a flex container with label left and amount right\n\n#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)\n\n### Right Column Sections\n\n#### Customer Card\n- Heading \"Customer\" (size md)\n- Separator\n- Customer name (or \"Guest\")\n- Customer email (muted text)\n- \"View customer\" link (shown only if customer exists, links to customer detail page)\n\n#### Shipping Address Card\n- Heading \"Shipping address\" (size md)\n- Separator\n- Full address rendered from shipping_address_json: line1, city/state/zip, country\n\n#### Billing Address Card\nSame pattern as shipping address, using billing_address_json.\n\n### Fulfillment Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-fulfillment` |\n| Max width | lg |\n| Heading | \"Create fulfillment\" |\n\n**Content:**\n- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n- Separator\n- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n- Tracking number input field\n- Tracking URL input field (type: url)\n- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n\n### Refund Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-refund` |\n| Max width | lg |\n| Heading | \"Refund order\" |\n\n**Content:**\n- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n- Separator\n- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n- Cancel button (ghost) and \"Create refund\" button (danger)\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Order number heading | `heading` |\n| Status badges | `badge` |\n| Action buttons | `button` |\n| Section headings | `heading` (size md) |\n| Section dividers | `separator` |\n| Text content | `text` |\n| Both modals | `modal` |\n| Modal form fields | `field`, `input`, `textarea` |\n| Line checkboxes | `checkbox` |\n| Modal buttons | `button` |\n| Icons | `icon` |\n| Callout messages | `callout` |\n\n---\n\n## 9. Customers List and Detail\n\n### 9.1 Customers List\n\n**Route:** `GET /admin/customers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by name or email |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `customers` | Paginated, searched customers with `withCount('orders')` and `withSum('orders', 'total_amount')` |\n\n#### Layout\n\n- Heading \"Customers\"\n- Search input\n- Table columns: Name, Email, Orders count, Total spent (formatted), Created date\n- Each row links to customer detail\n\n### 9.2 Customer Detail\n\n**Route:** `GET /admin/customers/{customer}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Show`\n\n**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `customer` | `Customer` | The customer model |\n| `editingAddress` | `?CustomerAddress` | Address currently being edited |\n| `addressLabel` | `string` | Label for address form |","numLines":250,"startLine":900,"totalLines":2403}},"sourceToolAssistantUUID":"9f847ec3-1161-4650-84c4-bd98ebd30983"}
{"parentUuid":"ea692fdb-4612-4826-8e60-9580b9784b38","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018it6JLCejuqr6GuCRqtoLJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018b1TSE4tByzmkd5DnbChn9","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","offset":900,"limit":250}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":49983,"cache_read_input_tokens":46730,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":49983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VnTPrD88LZWR1Fkaa4","type":"assistant","uuid":"968b5bfa-dcea-4660-b90d-e8ba54e88d71","timestamp":"2026-02-11T15:12:22.800Z"}
{"parentUuid":"968b5bfa-dcea-4660-b90d-e8ba54e88d71","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_018b1TSE4tByzmkd5DnbChn9","toolUseID":"toolu_018b1TSE4tByzmkd5DnbChn9","timestamp":"2026-02-11T15:12:22.802Z","uuid":"54042d67-3bac-434d-a493-ae10542d5ae9"}
{"parentUuid":"968b5bfa-dcea-4660-b90d-e8ba54e88d71","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018b1TSE4tByzmkd5DnbChn9","type":"tool_result","content":"   900→|  ------|------------- |---------|----------|---------|-------|\n   901→|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n   902→|        | Size: M      |         |          |         |       |\n   903→|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n   904→|        | Color: Blue  |         |          |         |       |\n   905→+--------------------------------------------------------------+\n   906→|                                            Subtotal: $84.97   |\n   907→|                              [Discount code] [Apply]          |\n   908→|                                            Discount: -$12.75  |\n   909→|                                            Total:    $72.22   |\n   910→|                                                               |\n   911→|                                            [Checkout button]  |\n   912→|                                            [Continue shopping]|\n   913→+--------------------------------------------------------------+\n   914→```\n   915→\n   916→| Attribute | Description |\n   917→|---|---|\n   918→| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n   919→| Title | H1, large bold, generous bottom margin |\n   920→| Desktop layout | Proper table with header row and body |\n   921→| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n   922→| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n   923→\n   924→### 7.2 Totals and Actions\n   925→\n   926→- Right-aligned on desktop, full-width on mobile\n   927→- Same discount code input/apply functionality as drawer\n   928→- Same totals breakdown\n   929→- \"Checkout\" button: primary, aligned right\n   930→- \"Continue shopping\" link: below checkout button\n   931→\n   932→### 7.3 Empty State\n   933→\n   934→Same as drawer empty state but centered in the page content area with more vertical padding.\n   935→\n   936→---\n   937→\n   938→## 8. Checkout Page (GET /checkout/{checkoutId})\n   939→\n   940→**Livewire Component:** CheckoutPage\n   941→\n   942→### 8.1 Layout\n   943→\n   944→**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n   945→\n   946→**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n   947→\n   948→```\n   949→Desktop:\n   950→+-------------------------------+-------------------+\n   951→|  [Checkout Form Sections]     | [Order Summary]   |\n   952→|  (wider, ~60%)                | (narrower, ~40%)  |\n   953→|                               | (sticky)          |\n   954→+-------------------------------+-------------------+\n   955→\n   956→Mobile:\n   957→+--------------------------------------------------------------+\n   958→|  [Order Summary - collapsible]                                |\n   959→+--------------------------------------------------------------+\n   960→|  [Checkout Form Sections]                                     |\n   961→+--------------------------------------------------------------+\n   962→```\n   963→\n   964→### 8.2 Checkout Steps\n   965→\n   966→The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.\n   967→\n   968→**Step State Behavior:**\n   969→\n   970→| State | Description |\n   971→|---|---|\n   972→| Completed | Shows summary of entered data with an \"Edit\" link |\n   973→| Current | Expanded with the full form |\n   974→| Future | Collapsed, muted, not interactive |\n   975→\n   976→Each step header shows the step number + title, e.g., \"1. Contact information\".\n   977→\n   978→#### Step 1 - Contact Information\n   979→\n   980→| Field | Description |\n   981→|---|---|\n   982→| Email | Full-width email input, required. Validated on blur |\n   983→| Account link | \"Already have an account? Log in\" link below email, small blue text |\n   984→| Continue button | Primary, below the form |\n   985→\n   986→#### Step 2 - Shipping Address\n   987→\n   988→Uses the address-form component.\n   989→\n   990→**Fields:**\n   991→\n   992→| Field | Type | Required | Layout Width |\n   993→|---|---|---|---|\n   994→| First name | Text | Yes | Half |\n   995→| Last name | Text | Yes | Half |\n   996→| Address line 1 | Text | Yes | Full |\n   997→| Address line 2 | Text | No | Full |\n   998→| City | Text | Yes | Half |\n   999→| State/Province | Select or text | Yes (varies by country) | Half |\n  1000→| Postal code | Text | Yes | Half |\n  1001→| Country | Select | Yes | Half |\n  1002→| Phone | Tel | No | Full |\n  1003→\n  1004→- Layout: 2-column grid, full-width fields span both columns\n  1005→- Country select: on change, State/Province updates to show relevant regions\n  1006→- All fields validate on blur\n  1007→\n  1008→**Billing address:**\n  1009→\n  1010→- Checkbox: \"Billing address same as shipping\" (checked by default)\n  1011→- When unchecked, a second address form appears for billing\n  1012→\n  1013→**Saved addresses (logged-in customers):**\n  1014→\n  1015→- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n  1016→- \"Use a new address\" option to show the blank form\n  1017→- On selection, form fields populate with the saved address data\n  1018→\n  1019→Continue button: primary.\n  1020→\n  1021→#### Step 3 - Shipping Method\n  1022→\n  1023→Radio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n  1024→\n  1025→```\n  1026→+--------------------------------------------------------------+\n  1027→| (o) Standard Shipping          5-7 business days       $7.99 |\n  1028→| ( ) Express Shipping           2-3 business days      $14.99 |\n  1029→| ( ) Free Shipping              7-10 business days      Free  |\n  1030→+--------------------------------------------------------------+\n  1031→```\n  1032→\n  1033→| Attribute | Description |\n  1034→|---|---|\n  1035→| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n  1036→| Label layout | Method name (left), estimated delivery (center), price (right) |\n  1037→| Live binding | On selection, the order summary sidebar updates totals |\n  1038→| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n  1039→\n  1040→Continue button: primary.\n  1041→\n  1042→#### Step 4 - Payment\n  1043→\n  1044→| Element | Description |\n  1045→|---|---|\n  1046→| Order review | Brief summary of items, address, and shipping method |\n  1047→| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n  1048→| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n  1049→| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n  1050→\n  1051→### 8.3 Order Summary Sidebar (Right Column)\n  1052→\n  1053→Sticky on desktop.\n  1054→\n  1055→```\n  1056→+-------------------------------+\n  1057→|  Order Summary                |\n  1058→+-------------------------------+\n  1059→|  [Thumb] Title x2     $49.98 |\n  1060→|          Size: M              |\n  1061→|  [Thumb] Title x1     $34.99 |\n  1062→+-------------------------------+\n  1063→|  [Discount code] [Apply]     |\n  1064→+-------------------------------+\n  1065→|  Subtotal              $84.97|\n  1066→|  Discount             -$12.75|\n  1067→|  Shipping               $7.99|\n  1068→|  Tax                    $5.74|\n  1069→|  ----------------------------|\n  1070→|  Total                 $85.95|\n  1071→+-------------------------------+\n  1072→```\n  1073→\n  1074→| Element | Description |\n  1075→|---|---|\n  1076→| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n  1077→| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n  1078→| Discount code input | Same as cart drawer |\n  1079→| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n  1080→| Updates | Event-driven from step completion actions (no polling) |\n  1081→\n  1082→### 8.4 Accessibility\n  1083→\n  1084→| Requirement | Description |\n  1085→|---|---|\n  1086→| Labels | All form inputs have associated label elements |\n  1087→| Validation errors | Linked via describedby pointing to error message element |\n  1088→| Required fields | Marked with required attribute and visual asterisk |\n  1089→| Step transitions | Focus moves to the first input of the newly expanded step |\n  1090→| Shipping radios | Grouped in a fieldset with legend |\n  1091→| Totals | Live region for screen reader updates |\n  1092→\n  1093→---\n  1094→\n  1095→## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n  1096→\n  1097→### 9.1 Layout\n  1098→\n  1099→Centered content, single column.\n  1100→\n  1101→```\n  1102→+--------------------------------------------------------------+\n  1103→|  [Checkmark icon - green circle]                              |\n  1104→|  \"Thank you for your order!\"                                  |\n  1105→|  Order #1042                                                  |\n  1106→|  \"We've sent a confirmation to you@email.com\"                 |\n  1107→+--------------------------------------------------------------+\n  1108→|  Order Summary                                                |\n  1109→|  [Items list with thumbnails]                                 |\n  1110→+--------------------------------------------------------------+\n  1111→|  Shipping Address          |  Payment Method                  |\n  1112→|  Jane Doe                  |  Visa ending in 4242             |\n  1113→|  123 Main St               |                                  |\n  1114→|  New York, NY 10001        |                                  |\n  1115→+--------------------------------------------------------------+\n  1116→|  Subtotal        $84.97                                       |\n  1117→|  Shipping         $7.99                                       |\n  1118→|  Tax              $5.74                                       |\n  1119→|  Total           $98.70                                       |\n  1120→+--------------------------------------------------------------+\n  1121→|  [Continue shopping button]  [View order - if logged in]      |\n  1122→+--------------------------------------------------------------+\n  1123→```\n  1124→\n  1125→| Element | Description |\n  1126→|---|---|\n  1127→| Container | Constrained to a comfortable width, centered, generous vertical padding |\n  1128→| Success icon | Large green checkmark in a circle, centered |\n  1129→| Heading | H1, large bold, centered, generous top margin |\n  1130→| Order number | Medium muted text, centered |\n  1131→| Email notice | Small muted text, centered |\n  1132→\n  1133→### 9.2 Order Details\n  1134→\n  1135→| Element | Description |\n  1136→|---|---|\n  1137→| Items | List with thumbnail, title, variant info, quantity, price |\n  1138→| Address and payment | Two-column grid on desktop, stacked on mobile |\n  1139→| Totals | Right-aligned summary |\n  1140→\n  1141→### 9.3 Actions\n  1142→\n  1143→| Button | Description |\n  1144→|---|---|\n  1145→| Continue shopping | Primary, links to home page |\n  1146→| View order | Link to account order detail page (logged-in customers only) |\n  1147→| Layout | Centered row with gap |\n  1148→\n  1149→---"}]},"uuid":"e41e3cef-d783-4f9d-90ff-f48ed06f2c80","timestamp":"2026-02-11T15:12:22.802Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"|  ------|------------- |---------|----------|---------|-------|\n|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n|        | Size: M      |         |          |         |       |\n|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n|        | Color: Blue  |         |          |         |       |\n+--------------------------------------------------------------+\n|                                            Subtotal: $84.97   |\n|                              [Discount code] [Apply]          |\n|                                            Discount: -$12.75  |\n|                                            Total:    $72.22   |\n|                                                               |\n|                                            [Checkout button]  |\n|                                            [Continue shopping]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n| Title | H1, large bold, generous bottom margin |\n| Desktop layout | Proper table with header row and body |\n| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n\n### 7.2 Totals and Actions\n\n- Right-aligned on desktop, full-width on mobile\n- Same discount code input/apply functionality as drawer\n- Same totals breakdown\n- \"Checkout\" button: primary, aligned right\n- \"Continue shopping\" link: below checkout button\n\n### 7.3 Empty State\n\nSame as drawer empty state but centered in the page content area with more vertical padding.\n\n---\n\n## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment\n\n| Element | Description |\n|---|---|\n| Order review | Brief summary of items, address, and shipping method |\n| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.\n\n```\n+--------------------------------------------------------------+\n|  [Checkmark icon - green circle]                              |\n|  \"Thank you for your order!\"                                  |\n|  Order #1042                                                  |\n|  \"We've sent a confirmation to you@email.com\"                 |\n+--------------------------------------------------------------+\n|  Order Summary                                                |\n|  [Items list with thumbnails]                                 |\n+--------------------------------------------------------------+\n|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Visa ending in 4242             |\n|  123 Main St               |                                  |\n|  New York, NY 10001        |                                  |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  [Continue shopping button]  [View order - if logged in]      |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained to a comfortable width, centered, generous vertical padding |\n| Success icon | Large green checkmark in a circle, centered |\n| Heading | H1, large bold, centered, generous top margin |\n| Order number | Medium muted text, centered |\n| Email notice | Small muted text, centered |\n\n### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Totals | Right-aligned summary |\n\n### 9.3 Actions\n\n| Button | Description |\n|---|---|\n| Continue shopping | Primary, links to home page |\n| View order | Link to account order detail page (logged-in customers only) |\n| Layout | Centered row with gap |\n\n---","numLines":250,"startLine":900,"totalLines":1835}},"sourceToolAssistantUUID":"968b5bfa-dcea-4660-b90d-e8ba54e88d71"}
{"parentUuid":"e41e3cef-d783-4f9d-90ff-f48ed06f2c80","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018it6JLCejuqr6GuCRqtoLJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KwtUksksBZ3jCAj8fAUbtn","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":1,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":49983,"cache_read_input_tokens":46730,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":49983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VnTPrD88LZWR1Fkaa4","type":"assistant","uuid":"8d599f19-3e4c-44a8-a84d-c7823fc84e91","timestamp":"2026-02-11T15:12:23.694Z"}
{"parentUuid":"8d599f19-3e4c-44a8-a84d-c7823fc84e91","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01KwtUksksBZ3jCAj8fAUbtn","toolUseID":"toolu_01KwtUksksBZ3jCAj8fAUbtn","timestamp":"2026-02-11T15:12:23.698Z","uuid":"8ab851c9-099a-43e9-a1a9-00439814c8d4"}
{"parentUuid":"8d599f19-3e4c-44a8-a84d-c7823fc84e91","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KwtUksksBZ3jCAj8fAUbtn","type":"tool_result","content":"     1→# 07 - Seeders and Test Data Specification\n     2→\n     3→> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n     4→\n     5→---\n     6→\n     7→## 1. DatabaseSeeder Orchestration\n     8→\n     9→The main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n    10→\n    11→**Execution order:**\n    12→\n    13→| Step | Seeder Class | Depends On | Purpose |\n    14→|------|-------------|------------|---------|\n    15→| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n    16→| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n    17→| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n    18→| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n    19→| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n    20→| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n    21→| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n    22→| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n    23→| 9 | CollectionSeeder | Stores | Creates product collections |\n    24→| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n    25→| 11 | DiscountSeeder | Stores | Creates discount codes |\n    26→| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n    27→| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n    28→| 14 | ThemeSeeder | Stores | Creates themes with settings |\n    29→| 15 | PageSeeder | Stores | Creates content pages |\n    30→| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n    31→| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n    32→| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n    33→\n    34→**Dependency diagram (Mermaid):**\n    35→\n    36→```mermaid\n    37→graph TD\n    38→    A[OrganizationSeeder] --> B[StoreSeeder]\n    39→    B --> C[StoreDomainSeeder]\n    40→    B --> F[StoreSettingsSeeder]\n    41→    B --> G[TaxSettingsSeeder]\n    42→    B --> H[ShippingSeeder]\n    43→    B --> I[CollectionSeeder]\n    44→    D[UserSeeder] --> E[StoreUserSeeder]\n    45→    B --> E\n    46→    B --> J[ProductSeeder]\n    47→    I --> J\n    48→    B --> K[DiscountSeeder]\n    49→    B --> L[CustomerSeeder]\n    50→    J --> M[OrderSeeder]\n    51→    L --> M\n    52→    K --> M\n    53→    B --> N[ThemeSeeder]\n    54→    B --> O[PageSeeder]\n    55→    I --> P[NavigationSeeder]\n    56→    O --> P\n    57→    B --> Q[AnalyticsSeeder]\n    58→    L --> Q\n    59→    B --> R[SearchSettingsSeeder]\n    60→```\n    61→\n    62→**Running seeders:**\n    63→- All seeders: `php artisan db:seed`\n    64→- Single seeder: `php artisan db:seed --class=ProductSeeder`\n    65→- Fresh database with seed data: `php artisan migrate:fresh --seed`\n    66→\n    67→---\n    68→\n    69→## 2. Factory Definitions\n    70→\n    71→Each factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n    72→\n    73→---\n    74→\n    75→### 2.1 OrganizationFactory\n    76→\n    77→**Model:** Organization\n    78→\n    79→**Default definition:**\n    80→\n    81→| Field | Generation Rule |\n    82→|-------|----------------|\n    83→| name | Faker company name |\n    84→| billing_email | Faker company email |\n    85→\n    86→**States:** None.\n    87→\n    88→---\n    89→\n    90→### 2.2 StoreFactory\n    91→\n    92→**Model:** Store\n    93→\n    94→**Default definition:**\n    95→\n    96→| Field | Generation Rule |\n    97→|-------|----------------|\n    98→| organization_id | Creates a new Organization via its factory |\n    99→| name | Faker company name + \" Store\" suffix |\n   100→| handle | Faker unique slug (2 words) |\n   101→| status | \"active\" |\n   102→| default_currency | \"EUR\" |\n   103→| default_locale | \"en\" |\n   104→| timezone | \"Europe/Berlin\" |\n   105→\n   106→**States:**\n   107→\n   108→| State Name | Overrides |\n   109→|-----------|-----------|\n   110→| `suspended` | status = \"suspended\" |\n   111→\n   112→---\n   113→\n   114→### 2.3 StoreDomainFactory\n   115→\n   116→**Model:** StoreDomain\n   117→\n   118→**Default definition:**\n   119→\n   120→| Field | Generation Rule |\n   121→|-------|----------------|\n   122→| store_id | Creates a new Store via its factory |\n   123→| hostname | Faker unique domain name |\n   124→| type | \"storefront\" |\n   125→| is_primary | 1 |\n   126→| tls_mode | \"managed\" |\n   127→\n   128→**States:**\n   129→\n   130→| State Name | Overrides |\n   131→|-----------|-----------|\n   132→| `admin` | type = \"admin\" |\n   133→| `api` | type = \"api\" |\n   134→| `secondary` | is_primary = 0 |\n   135→\n   136→---\n   137→\n   138→### 2.4 UserFactory\n   139→\n   140→**Model:** User\n   141→\n   142→**Default definition:**\n   143→\n   144→| Field | Generation Rule |\n   145→|-------|----------------|\n   146→| email | Faker unique safe email |\n   147→| password_hash | bcrypt of \"password\" |\n   148→| name | Faker full name |\n   149→| status | \"active\" |\n   150→| last_login_at | Random date within the last 30 days |\n   151→\n   152→**States:**\n   153→\n   154→| State Name | Overrides |\n   155→|-----------|-----------|\n   156→| `disabled` | status = \"disabled\" |\n   157→\n   158→---\n   159→\n   160→### 2.5 ProductFactory\n   161→\n   162→**Model:** Product\n   163→\n   164→**Default definition:**\n   165→\n   166→| Field | Generation Rule |\n   167→|-------|----------------|\n   168→| store_id | Creates a new Store via its factory |\n   169→| title | Faker 3 random words joined |\n   170→| handle | URL-slug of the title |\n   171→| status | \"active\" |\n   172→| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n   173→| vendor | Faker company name |\n   174→| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n   175→| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n   176→| published_at | Current timestamp |\n   177→\n   178→**States:**\n   179→\n   180→| State Name | Overrides |\n   181→|-----------|-----------|\n   182→| `draft` | status = \"draft\", published_at = null |\n   183→| `archived` | status = \"archived\" |\n   184→\n   185→**Relationship hooks:**\n   186→\n   187→| Hook Name | Behavior |\n   188→|-----------|----------|\n   189→| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n   190→| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n   191→\n   192→---\n   193→\n   194→### 2.6 ProductVariantFactory\n   195→\n   196→**Model:** ProductVariant\n   197→\n   198→**Default definition:**\n   199→\n   200→| Field | Generation Rule |"}]},"uuid":"1d499105-3f5c-4bda-8dad-a277be4db5fc","timestamp":"2026-02-11T15:12:23.697Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |","numLines":200,"startLine":1,"totalLines":2215}},"sourceToolAssistantUUID":"8d599f19-3e4c-44a8-a84d-c7823fc84e91"}
{"parentUuid":"1d499105-3f5c-4bda-8dad-a277be4db5fc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01NaoDinhupRhqWTRstXKMwq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a good view of the files. Let me also read the orders section from the seeders file and the remaining spec files I need to update.","signature":"ErYCCkYICxgCKkCQyE6SKH+4XxofyvJ7J2guJNNt8LCN2n8RgMKsZG8WkVhxpJDXTJkSEK41zHHg6qHQJj4VRXp7by0YMc9pTa2/EgxejMV8V4Jjwx1EDgoaDB2Ir36V2oeQX5mWeCIwS9l6wUpxpoVZcJKsTciiRDb4vmEbGrgJVETL11ZF72uOutyAwxytVt8ax+ZkqB0cKp0BX0wPcA53s57LYl7+2vS92nof7DzjKo3sb17i452KoEU6JbKXFbHkj9ypNwUjAjqA5bnLsLyJDEeSFMi0pCR6sI/Mr0RQ9+FwQBtHv6gzlKuEA+fT2mRkYvgJjPIhVYKziLOmrjHrig5XHbXjI9lKWE36bYlfsbwrJhPloZaHwTDZDPTdmQoP98rzEalMp+qQBm9lwk8O344npuXWLxgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16844,"cache_read_input_tokens":96713,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":16844},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vo6Mqfm36GArBDc8HZ","type":"assistant","uuid":"b96480e8-152a-4074-879a-90fb29604ef3","timestamp":"2026-02-11T15:12:27.115Z"}
{"parentUuid":"b96480e8-152a-4074-879a-90fb29604ef3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01NaoDinhupRhqWTRstXKMwq","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the remaining sections I need - the orders seeder data and the other spec files."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16844,"cache_read_input_tokens":96713,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":16844},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vo6Mqfm36GArBDc8HZ","type":"assistant","uuid":"bb34e9b9-22e3-4cf7-83ef-efa74d082022","timestamp":"2026-02-11T15:12:27.801Z"}
{"parentUuid":"bb34e9b9-22e3-4cf7-83ef-efa74d082022","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01NaoDinhupRhqWTRstXKMwq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TbZKH2w2u47E1JcFZwhLQV","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":1200,"limit":600}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16844,"cache_read_input_tokens":96713,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":16844},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vo6Mqfm36GArBDc8HZ","type":"assistant","uuid":"24ab4c48-7a39-4809-b81f-1936135fce26","timestamp":"2026-02-11T15:12:29.221Z"}
{"parentUuid":"24ab4c48-7a39-4809-b81f-1936135fce26","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01TbZKH2w2u47E1JcFZwhLQV","toolUseID":"toolu_01TbZKH2w2u47E1JcFZwhLQV","timestamp":"2026-02-11T15:12:29.227Z","uuid":"18c12994-fc07-483b-b42a-e08e69166a6f"}
{"parentUuid":"24ab4c48-7a39-4809-b81f-1936135fce26","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TbZKH2w2u47E1JcFZwhLQV","type":"tool_result","content":"  1200→| Field | Value |\n  1201→|-------|-------|\n  1202→| handle | canvas-tote-bag |\n  1203→| status | active |\n  1204→| vendor | Acme Accessories |\n  1205→| product_type | Accessories |\n  1206→| tags | [\"trending\"] |\n  1207→| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n  1208→| published_at | now |\n  1209→\n  1210→Collections: (none)\n  1211→\n  1212→Options: Color (Natural, Black)\n  1213→\n  1214→Variants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n  1215→\n  1216→---\n  1217→\n  1218→**Product 14: \"Bucket Hat\"**\n  1219→\n  1220→| Field | Value |\n  1221→|-------|-------|\n  1222→| handle | bucket-hat |\n  1223→| status | active |\n  1224→| vendor | Acme Accessories |\n  1225→| product_type | Accessories |\n  1226→| tags | [\"new\", \"trending\"] |\n  1227→| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n  1228→| published_at | now |\n  1229→\n  1230→Collections: New Arrivals\n  1231→\n  1232→Options: Size (S/M, L/XL), Color (Beige, Black, Olive)\n  1233→\n  1234→Variants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n  1235→\n  1236→---\n  1237→\n  1238→**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n  1239→\n  1240→| Field | Value |\n  1241→|-------|-------|\n  1242→| handle | unreleased-winter-jacket |\n  1243→| status | **draft** |\n  1244→| vendor | Acme Outerwear |\n  1245→| product_type | Jackets |\n  1246→| tags | [\"limited\"] |\n  1247→| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n  1248→| published_at | **null** |\n  1249→\n  1250→Collections: (none - draft not assigned)\n  1251→\n  1252→Options: Size (S, M, L, XL)\n  1253→\n  1254→Variants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n  1255→\n  1256→**Test purpose:** Verifies draft products do not appear on the storefront.\n  1257→\n  1258→---\n  1259→\n  1260→**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n  1261→\n  1262→| Field | Value |\n  1263→|-------|-------|\n  1264→| handle | discontinued-raincoat |\n  1265→| status | **archived** |\n  1266→| vendor | Acme Outerwear |\n  1267→| product_type | Jackets |\n  1268→| tags | [] |\n  1269→| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n  1270→| published_at | 6 months ago |\n  1271→\n  1272→Collections: (none)\n  1273→\n  1274→Options: Size (M, L)\n  1275→\n  1276→Variants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n  1277→\n  1278→**Test purpose:** Verifies archived products do not appear on the storefront.\n  1279→\n  1280→---\n  1281→\n  1282→**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n  1283→\n  1284→| Field | Value |\n  1285→|-------|-------|\n  1286→| handle | limited-edition-sneakers |\n  1287→| status | active |\n  1288→| vendor | Acme Sport |\n  1289→| product_type | Shoes |\n  1290→| tags | [\"limited\"] |\n  1291→| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n  1292→| published_at | now |\n  1293→\n  1294→Collections: (none)\n  1295→\n  1296→Options: Size (EU 40, EU 42, EU 44)\n  1297→\n  1298→Variants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n  1299→\n  1300→**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n  1301→\n  1302→---\n  1303→\n  1304→**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n  1305→\n  1306→| Field | Value |\n  1307→|-------|-------|\n  1308→| handle | backorder-denim-jacket |\n  1309→| status | active |\n  1310→| vendor | Acme Denim |\n  1311→| product_type | Jackets |\n  1312→| tags | [\"popular\"] |\n  1313→| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n  1314→| published_at | now |\n  1315→\n  1316→Collections: (none)\n  1317→\n  1318→Options: Size (S, M, L, XL)\n  1319→\n  1320→Variants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n  1321→\n  1322→**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n  1323→\n  1324→---\n  1325→\n  1326→**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n  1327→\n  1328→| Field | Value |\n  1329→|-------|-------|\n  1330→| handle | gift-card |\n  1331→| status | active |\n  1332→| vendor | Acme Fashion |\n  1333→| product_type | Gift Cards |\n  1334→| tags | [\"popular\"] |\n  1335→| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n  1336→| published_at | now |\n  1337→\n  1338→Collections: (none)\n  1339→\n  1340→Options: Amount (25 EUR, 50 EUR, 100 EUR)\n  1341→\n  1342→Variants (3 total - each a different denomination):\n  1343→\n  1344→| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n  1345→|-----|---------------|--------|-------------------|-----------|\n  1346→| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n  1347→| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n  1348→| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n  1349→\n  1350→**Test purpose:** Tests digital product flow (no shipping step in checkout).\n  1351→\n  1352→---\n  1353→\n  1354→**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n  1355→\n  1356→| Field | Value |\n  1357→|-------|-------|\n  1358→| handle | cashmere-overcoat |\n  1359→| status | active |\n  1360→| vendor | Acme Premium |\n  1361→| product_type | Jackets |\n  1362→| tags | [\"limited\", \"new\"] |\n  1363→| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n  1364→| published_at | now |\n  1365→\n  1366→Collections: New Arrivals\n  1367→\n  1368→Options: Size (S, M, L), Color (Camel, Charcoal)\n  1369→\n  1370→Variants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n  1371→\n  1372→**Test purpose:** Tests high-value price display (499.99 EUR).\n  1373→\n  1374→---\n  1375→\n  1376→#### Product Summary Table (Acme Fashion)\n  1377→\n  1378→| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n  1379→|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n  1380→| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n  1381→| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n  1382→| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n  1383→| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n  1384→| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n  1385→| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n  1386→| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n  1387→| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n  1388→| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n  1389→| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n  1390→| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n  1391→| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n  1392→| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n  1393→| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n  1394→| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n  1395→| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n  1396→| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n  1397→| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n  1398→| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n  1399→| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n  1400→\n  1401→**Total variants across Acme Fashion:** 107\n  1402→\n  1403→---\n  1404→\n  1405→#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n  1406→\n  1407→These products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n  1408→\n  1409→| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n  1410→|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n  1411→| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n  1412→| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n  1413→| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n  1414→| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n  1415→| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n  1416→\n  1417→All active, published now, tags vary. Inventory policy = \"deny\" for all.\n  1418→\n  1419→**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n  1420→\n  1421→---\n  1422→\n  1423→### 3.11 DiscountSeeder\n  1424→\n  1425→**Acme Fashion - 5 discount codes:**\n  1426→\n  1427→| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n  1428→|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n  1429→| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n  1430→| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n  1431→| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n  1432→| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n  1433→| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n  1434→\n  1435→**Behavioral expectations:**\n  1436→- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n  1437→- FLAT5: Applies flat 5.00 EUR discount. Never used.\n  1438→- FREESHIP: Removes shipping cost. Used once.\n  1439→- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n  1440→- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n  1441→\n  1442→---\n  1443→\n  1444→### 3.12 CustomerSeeder\n  1445→\n  1446→**Acme Fashion - 10 customers:**\n  1447→\n  1448→| # | Email | Name | Password (plaintext) | marketing_opt_in |\n  1449→|---|-------|------|----------------------|------------------|\n  1450→| 1 | customer@acme.test | John Doe | password | 1 |\n  1451→| 2 | jane@example.com | Jane Smith | password | 0 |\n  1452→| 3 | michael@example.com | Michael Brown | password | 1 |\n  1453→| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n  1454→| 5 | david@example.com | David Lee | password | 1 |\n  1455→| 6 | emma@example.com | Emma Garcia | password | 0 |\n  1456→| 7 | james@example.com | James Taylor | password | 0 |\n  1457→| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n  1458→| 9 | robert@example.com | Robert Martinez | password | 0 |\n  1459→| 10 | anna@example.com | Anna Thomas | password | 1 |\n  1460→\n  1461→**Customer 1 (customer@acme.test) addresses:**\n  1462→\n  1463→| Label | is_default | Address Details |\n  1464→|-------|-----------|-----------------|\n  1465→| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n  1466→| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n  1467→\n  1468→**Customer 2 (jane@example.com) addresses:**\n  1469→\n  1470→| Label | is_default | Address Details |\n  1471→|-------|-----------|-----------------|\n  1472→| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n  1473→\n  1474→**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n  1475→\n  1476→**Acme Electronics - 2 customers (tenant isolation testing):**\n  1477→\n  1478→| # | Email | Name | Password |\n  1479→|---|-------|------|----------|\n  1480→| 1 | techfan@example.com | Tech Fan | password |\n  1481→| 2 | gadgetlover@example.com | Gadget Lover | password |\n  1482→\n  1483→Each has one default address with Faker-generated data.\n  1484→\n  1485→---\n  1486→\n  1487→### 3.13 OrderSeeder\n  1488→\n  1489→**Acme Fashion - 15 orders:**\n  1490→\n  1491→All orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n  1492→\n  1493→---\n  1494→\n  1495→**Order #1001 - Awaiting fulfillment**\n  1496→\n  1497→| Field | Value |\n  1498→|-------|-------|\n  1499→| customer | customer@acme.test (John Doe) |\n  1500→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1501→| placed_at | 2 days ago |\n  1502→\n  1503→Lines:\n  1504→\n  1505→| Product | Variant (option values) | Qty | Unit Price | Line Total |\n  1506→|---------|------------------------|-----|-----------|-----------|\n  1507→| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n  1508→\n  1509→Totals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n  1510→\n  1511→Payment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n  1512→\n  1513→Fulfillments: None.\n  1514→\n  1515→---\n  1516→\n  1517→**Order #1002 - Fully delivered**\n  1518→\n  1519→| Field | Value |\n  1520→|-------|-------|\n  1521→| customer | customer@acme.test (John Doe) |\n  1522→| status / financial / fulfillment | fulfilled / paid / fulfilled |\n  1523→| placed_at | 10 days ago |\n  1524→\n  1525→Lines:\n  1526→\n  1527→| Product | Variant | Qty | Unit Price | Line Total |\n  1528→|---------|---------|-----|-----------|-----------|\n  1529→| Organic Hoodie | M | 1 | 5999 | 5999 |\n  1530→| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n  1531→\n  1532→Totals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n  1533→\n  1534→Payment: stripe, \"pi_test_order1002\", captured, 8997\n  1535→\n  1536→Fulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n  1537→\n  1538→---\n  1539→\n  1540→**Order #1003 - Partially fulfilled**\n  1541→\n  1542→| Field | Value |\n  1543→|-------|-------|\n  1544→| customer | jane@example.com (Jane Smith) |\n  1545→| status / financial / fulfillment | paid / paid / partial |\n  1546→| placed_at | 5 days ago |\n  1547→\n  1548→Lines:\n  1549→\n  1550→| Product | Variant | Qty | Unit Price | Line Total |\n  1551→|---------|---------|-----|-----------|-----------|\n  1552→| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n  1553→| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n  1554→\n  1555→Totals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n  1556→\n  1557→Payment: stripe, \"pi_test_order1003\", captured, 11997\n  1558→\n  1559→Fulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n  1560→\n  1561→---\n  1562→\n  1563→**Order #1004 - Cancelled with full refund**\n  1564→\n  1565→| Field | Value |\n  1566→|-------|-------|\n  1567→| customer | customer@acme.test (John Doe) |\n  1568→| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n  1569→| placed_at | 15 days ago |\n  1570→\n  1571→Lines:\n  1572→\n  1573→| Product | Variant | Qty | Unit Price | Line Total |\n  1574→|---------|---------|-----|-----------|-----------|\n  1575→| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n  1576→\n  1577→Totals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n  1578→\n  1579→Payment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n  1580→\n  1581→Refund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n  1582→\n  1583→---\n  1584→\n  1585→**Order #1005 - Payment pending**\n  1586→\n  1587→| Field | Value |\n  1588→|-------|-------|\n  1589→| customer | jane@example.com (Jane Smith) |\n  1590→| status / financial / fulfillment | pending / pending / unfulfilled |\n  1591→| placed_at | 2 hours ago |\n  1592→\n  1593→Lines:\n  1594→\n  1595→| Product | Variant | Qty | Unit Price | Line Total |\n  1596→|---------|---------|-----|-----------|-----------|\n  1597→| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n  1598→\n  1599→Totals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n  1600→\n  1601→Payment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n  1602→\n  1603→---\n  1604→\n  1605→**Order #1006 - Standard paid order**\n  1606→\n  1607→| Field | Value |\n  1608→|-------|-------|\n  1609→| customer | michael@example.com (Michael Brown) |\n  1610→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1611→| placed_at | 1 day ago |\n  1612→\n  1613→Lines:\n  1614→\n  1615→| Product | Variant | Qty | Unit Price | Line Total |\n  1616→|---------|---------|-----|-----------|-----------|\n  1617→| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n  1618→\n  1619→Totals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n  1620→\n  1621→Payment: stripe, \"pi_test_order1006\", captured, 12498\n  1622→\n  1623→---\n  1624→\n  1625→**Order #1007 - Multi-item delivered**\n  1626→\n  1627→| Field | Value |\n  1628→|-------|-------|\n  1629→| customer | sarah@example.com (Sarah Wilson) |\n  1630→| status / financial / fulfillment | fulfilled / paid / fulfilled |\n  1631→| placed_at | 20 days ago |\n  1632→\n  1633→Lines:\n  1634→\n  1635→| Product | Variant | Qty | Unit Price | Line Total |\n  1636→|---------|---------|-----|-----------|-----------|\n  1637→| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n  1638→| Wool Scarf | Grey | 1 | 2999 | 2999 |\n  1639→\n  1640→Totals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n  1641→\n  1642→Payment: stripe, captured, 10496\n  1643→\n  1644→Fulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n  1645→\n  1646→---\n  1647→\n  1648→**Order #1008 - Partial refund**\n  1649→\n  1650→| Field | Value |\n  1651→|-------|-------|\n  1652→| customer | david@example.com (David Lee) |\n  1653→| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n  1654→| placed_at | 12 days ago |\n  1655→\n  1656→Lines:\n  1657→\n  1658→| Product | Variant | Qty | Unit Price | Line Total |\n  1659→|---------|---------|-----|-----------|-----------|\n  1660→| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n  1661→| Graphic Print Tee | L | 1 | 2999 | 2999 |\n  1662→\n  1663→Totals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n  1664→\n  1665→Payment: stripe, captured, 8997\n  1666→\n  1667→Fulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n  1668→\n  1669→Refund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n  1670→\n  1671→---\n  1672→\n  1673→**Order #1009 - Accessories order**\n  1674→\n  1675→| Field | Value |\n  1676→|-------|-------|\n  1677→| customer | emma@example.com (Emma Garcia) |\n  1678→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1679→| placed_at | 3 days ago |\n  1680→\n  1681→Lines:\n  1682→\n  1683→| Product | Variant | Qty | Unit Price | Line Total |\n  1684→|---------|---------|-----|-----------|-----------|\n  1685→| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n  1686→| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n  1687→\n  1688→Totals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n  1689→\n  1690→Payment: stripe, captured, 4997\n  1691→\n  1692→---\n  1693→\n  1694→**Order #1010 - High-value order**\n  1695→\n  1696→| Field | Value |\n  1697→|-------|-------|\n  1698→| customer | customer@acme.test (John Doe) |\n  1699→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1700→| placed_at | 1 day ago |\n  1701→\n  1702→Lines:\n  1703→\n  1704→| Product | Variant | Qty | Unit Price | Line Total |\n  1705→|---------|---------|-----|-----------|-----------|\n  1706→| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n  1707→\n  1708→Totals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n  1709→\n  1710→Payment: stripe, captured, 50498\n  1711→\n  1712→---\n  1713→\n  1714→**Order #1011 - Single item delivered**\n  1715→\n  1716→| Field | Value |\n  1717→|-------|-------|\n  1718→| customer | james@example.com (James Taylor) |\n  1719→| status / financial / fulfillment | paid / paid / fulfilled |\n  1720→| placed_at | 25 days ago |\n  1721→\n  1722→Lines:\n  1723→\n  1724→| Product | Variant | Qty | Unit Price | Line Total |\n  1725→|---------|---------|-----|-----------|-----------|\n  1726→| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n  1727→\n  1728→Totals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n  1729→\n  1730→Payment: stripe, captured, 3298\n  1731→\n  1732→Fulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n  1733→\n  1734→---\n  1735→\n  1736→**Order #1012 - Multi-quantity order**\n  1737→\n  1738→| Field | Value |\n  1739→|-------|-------|\n  1740→| customer | lisa@example.com (Lisa Anderson) |\n  1741→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1742→| placed_at | 4 days ago |\n  1743→\n  1744→Lines:\n  1745→\n  1746→| Product | Variant | Qty | Unit Price | Line Total |\n  1747→|---------|---------|-----|-----------|-----------|\n  1748→| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n  1749→\n  1750→Totals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n  1751→\n  1752→Payment: stripe, captured, 8497\n  1753→\n  1754→---\n  1755→\n  1756→**Order #1013 - Multi-item order**\n  1757→\n  1758→| Field | Value |\n  1759→|-------|-------|\n  1760→| customer | robert@example.com (Robert Martinez) |\n  1761→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1762→| placed_at | 1 day ago |\n  1763→\n  1764→Lines:\n  1765→\n  1766→| Product | Variant | Qty | Unit Price | Line Total |\n  1767→|---------|---------|-----|-----------|-----------|\n  1768→| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n  1769→| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n  1770→\n  1771→Totals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n  1772→\n  1773→Payment: stripe, captured, 8497\n  1774→\n  1775→---\n  1776→\n  1777→**Order #1014 - Digital product order**\n  1778→\n  1779→| Field | Value |\n  1780→|-------|-------|\n  1781→| customer | anna@example.com (Anna Thomas) |\n  1782→| status / financial / fulfillment | paid / paid / fulfilled |\n  1783→| placed_at | 14 days ago |\n  1784→\n  1785→Lines:\n  1786→\n  1787→| Product | Variant | Qty | Unit Price | Line Total |\n  1788→|---------|---------|-----|-----------|-----------|\n  1789→| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n  1790→\n  1791→Totals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n  1792→\n  1793→Payment: stripe, captured, 5000\n  1794→\n  1795→Fulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n  1796→\n  1797→---\n  1798→\n  1799→**Order #1015 - Order with discount**"}]},"uuid":"e305eb12-4497-47ef-a684-c0222b759ad5","timestamp":"2026-02-11T15:12:29.226Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**","numLines":600,"startLine":1200,"totalLines":2215}},"sourceToolAssistantUUID":"24ab4c48-7a39-4809-b81f-1936135fce26"}
{"parentUuid":"e305eb12-4497-47ef-a684-c0222b759ad5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01NaoDinhupRhqWTRstXKMwq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UF3sifQJs6o2wf2fAphdMC","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":1,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16844,"cache_read_input_tokens":96713,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":16844},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vo6Mqfm36GArBDc8HZ","type":"assistant","uuid":"5078fb7c-6c9f-43e8-a992-ea77d912d9c7","timestamp":"2026-02-11T15:12:30.033Z"}
{"parentUuid":"5078fb7c-6c9f-43e8-a992-ea77d912d9c7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01UF3sifQJs6o2wf2fAphdMC","toolUseID":"toolu_01UF3sifQJs6o2wf2fAphdMC","timestamp":"2026-02-11T15:12:30.036Z","uuid":"20d66dca-bb2b-44bb-b68f-53033122d1fe"}
{"parentUuid":"5078fb7c-6c9f-43e8-a992-ea77d912d9c7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UF3sifQJs6o2wf2fAphdMC","type":"tool_result","content":"     1→# 08 - Playwright E2E Test Plan\n     2→\n     3→> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n     4→\n     5→---\n     6→\n     7→## Test Suite Summary\n     8→\n     9→| Suite | File | Tests | Purpose | Dependencies |\n    10→|-------|------|:-----:|---------|--------------|\n    11→| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n    12→| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n    13→| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n    14→| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |\n    15→| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n    16→| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n    17→| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n    18→| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n    19→| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |\n    20→| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n    21→| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n    22→| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n    23→| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n    24→| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n    25→| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n    26→| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n    27→| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n    28→| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n    29→| **Total** | **18 files** | **134** | | |\n    30→\n    31→---\n    32→\n    33→## Test Infrastructure Requirements\n    34→\n    35→### Framework\n    36→\n    37→- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n    38→- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n    39→- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n    40→- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n    41→\n    42→### Pest v4 Browser Testing API Reference\n    43→\n    44→The coding agent should use these Pest v4 browser methods:\n    45→\n    46→| Method | Purpose |\n    47→|--------|---------|\n    48→| `visit(url)` | Open a real browser and navigate to the given URL |\n    49→| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n    50→| `fill(field, value)` | Type a value into a named form field |\n    51→| `click(text)` | Click a visible element matching the given text |\n    52→| `assertSee(text)` | Assert the page contains the given visible text |\n    53→| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n    54→| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n    55→\n    56→Additionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n    57→\n    58→### Environment Configuration\n    59→\n    60→The `.env.testing` file must contain the following keys:\n    61→\n    62→| Key | Value | Purpose |\n    63→|-----|-------|---------|\n    64→| `APP_ENV` | `testing` | Laravel environment |\n    65→| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n    66→| `DB_CONNECTION` | `sqlite` | Database driver |\n    67→| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n    68→| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n    69→| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n    70→| `MAIL_MAILER` | `array` | Trap emails in memory |\n    71→| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n    72→\n    73→### Prerequisites Before Running\n    74→\n    75→1. Database seeded with `php artisan migrate:fresh --seed`\n    76→2. Laravel Herd serving `acme-fashion.test`\n    77→3. Playwright browsers installed via `npx playwright install`\n    78→4. Frontend assets built via `npm run build`\n    79→\n    80→---\n    81→\n    82→## Seeded Data Dependency Table\n    83→\n    84→All tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n    85→\n    86→Each browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n    87→\n    88→| Entity | Key Details | Used By Suites |\n    89→|--------|-------------|----------------|\n    90→| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n    91→| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n    92→| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n    93→| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n    94→| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n    95→| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n    96→| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n    97→| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n    98→| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n    99→| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n   100→| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n   101→| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n   102→| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n   103→| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n   104→| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n   105→| **Navigation** | Main menu with links to collections, about page | 7 |\n   106→\n   107→---\n   108→\n   109→## Test Execution Matrix\n   110→\n   111→Tests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n   112→\n   113→```mermaid\n   114→graph TD\n   115→    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n   116→    S1 --> S7[Suite 7: Storefront Browsing]\n   117→    S2 --> S3[Suite 3: Admin Products]\n   118→    S2 --> S4[Suite 4: Admin Orders]\n   119→    S2 --> S5[Suite 5: Admin Discounts]\n   120→    S2 --> S6[Suite 6: Admin Settings]\n   121→    S2 --> S15[Suite 15: Admin Collections]\n   122→    S2 --> S16[Suite 16: Admin Customers]\n   123→    S2 --> S17[Suite 17: Admin Pages]\n   124→    S2 --> S18[Suite 18: Admin Analytics]\n   125→    S7 --> S8[Suite 8: Cart Flow]\n   126→    S8 --> S9[Suite 9: Checkout Flow]\n   127→    S7 --> S10[Suite 10: Customer Account]\n   128→    S7 --> S11[Suite 11: Inventory Enforcement]\n   129→    S7 --> S12[Suite 12: Tenant Isolation]\n   130→    S7 --> S13[Suite 13: Responsive / Mobile]\n   131→    S7 --> S14[Suite 14: Accessibility]\n   132→```\n   133→\n   134→| Execution Phase | Suites | Rationale |\n   135→|-----------------|--------|-----------|\n   136→| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n   137→| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n   138→| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n   139→| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n   140→| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n   141→| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n   142→\n   143→---\n   144→\n   145→## Test Directory Structure\n   146→\n   147→```\n   148→tests/Browser/\n   149→    SmokeTest.php\n   150→    Admin/\n   151→        AuthenticationTest.php\n   152→        ProductManagementTest.php\n   153→        OrderManagementTest.php\n   154→        DiscountManagementTest.php\n   155→        SettingsTest.php\n   156→        CollectionManagementTest.php\n   157→        CustomerManagementTest.php\n   158→        PageManagementTest.php\n   159→        AnalyticsTest.php\n   160→    Storefront/\n   161→        BrowsingTest.php\n   162→        CartTest.php\n   163→        CheckoutTest.php\n   164→        CustomerAccountTest.php\n   165→        InventoryTest.php\n   166→        TenantIsolationTest.php\n   167→        ResponsiveTest.php\n   168→        AccessibilityTest.php\n   169→```\n   170→\n   171→---\n   172→\n   173→## Suite 1: Smoke Tests\n   174→\n   175→**File:** `tests/Browser/SmokeTest.php`\n   176→\n   177→**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n   178→\n   179→---\n   180→\n   181→### Test 1.1: Loads the storefront home page\n   182→\n   183→**Preconditions:** Seeded store\n   184→\n   185→**Steps:**\n   186→1. Navigate to `/`\n   187→\n   188→**Expected Results:**\n   189→- Page displays \"Acme Fashion\"\n   190→- No JavaScript errors\n   191→\n   192→---\n   193→\n   194→### Test 1.2: Loads a collection page\n   195→\n   196→**Preconditions:** Seeded collections\n   197→\n   198→**Steps:**\n   199→1. Navigate to `/collections/t-shirts`\n   200→"}]},"uuid":"78d7e1cc-3363-419e-b059-f8c2a81b7485","timestamp":"2026-02-11T15:12:30.036Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **134** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n","numLines":200,"startLine":1,"totalLines":2977}},"sourceToolAssistantUUID":"5078fb7c-6c9f-43e8-a992-ea77d912d9c7"}
{"parentUuid":"78d7e1cc-3363-419e-b059-f8c2a81b7485","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01NaoDinhupRhqWTRstXKMwq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014AwE2v2K2rzD9wQfMqGZoo","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16844,"cache_read_input_tokens":96713,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":16844},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vo6Mqfm36GArBDc8HZ","type":"assistant","uuid":"856288d3-ffe9-4d41-8311-97863e33bcf6","timestamp":"2026-02-11T15:12:31.556Z"}
{"parentUuid":"856288d3-ffe9-4d41-8311-97863e33bcf6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_014AwE2v2K2rzD9wQfMqGZoo","toolUseID":"toolu_014AwE2v2K2rzD9wQfMqGZoo","timestamp":"2026-02-11T15:12:31.561Z","uuid":"57c61edf-ce66-4531-b6c8-e0ae6095d86f"}
{"parentUuid":"856288d3-ffe9-4d41-8311-97863e33bcf6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014AwE2v2K2rzD9wQfMqGZoo","type":"tool_result","content":"     1→# 09 - Implementation Roadmap and Pest Test Specification\n     2→\n     3→> Shopify-clone 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 | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\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":"a2368b6a-1cd8-4245-a8a6-656d16f3ab35","timestamp":"2026-02-11T15:12:31.561Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| Payments | Stripe + PayPal | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\n| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n","numLines":200,"startLine":1,"totalLines":2027}},"sourceToolAssistantUUID":"856288d3-ffe9-4d41-8311-97863e33bcf6"}
{"parentUuid":"a2368b6a-1cd8-4245-a8a6-656d16f3ab35","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012fkmLY323FpRkdtzRLaNBf","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me also read the remaining part of the OrderSeeder to see Order #1015 and the Payment factory section.","signature":"EpYCCkYICxgCKkCOKmRLhm7VdNGaTjsYiRO/Q7IDe36p7cWyP4foJyOXQkkPs642eWBaiQ4nUO2v9ok/RYgq5aI4OPVTUzIvIYtOEgzj67Xvm6KTarQODf8aDKaK0yszkV08ervxnyIw5kTr+SZv5vN+pc5cs5oxEmAkEGiZe6L0zxNkgj5/iiSxLYqFi4tzgQaTU96PWv07Kn6N12PeeArcRT3308lbw1lk+XMHq3Tw9FTClh3o4dAVbb226X3U6jVUGQzodhE+3ELbr44OCYnKnXju/iqTfdStMK6WJQ6amrDvPFmS1WHDrKCg47clAxvmdBZEJMG0LoNoRtoLAgw7tH/VQXd+ta+zk6JYpKdlOeC24m5kZo4YAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":17442,"cache_read_input_tokens":113557,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":17442},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VogHX1aaLmPg7jugHF","type":"assistant","uuid":"882e1a89-92be-49dc-8e81-e7638423f1f7","timestamp":"2026-02-11T15:12:35.026Z"}
{"parentUuid":"882e1a89-92be-49dc-8e81-e7638423f1f7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012fkmLY323FpRkdtzRLaNBf","type":"message","role":"assistant","content":[{"type":"text","text":"Let me get the last order and the payment factory."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":17442,"cache_read_input_tokens":113557,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":17442},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VogHX1aaLmPg7jugHF","type":"assistant","uuid":"2f6f326f-c4c4-42da-a9c0-4357a3ca8c5c","timestamp":"2026-02-11T15:12:35.639Z"}
{"parentUuid":"2f6f326f-c4c4-42da-a9c0-4357a3ca8c5c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012fkmLY323FpRkdtzRLaNBf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0189F2RHcvst2UcPRan6or6k","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":1799,"limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":17442,"cache_read_input_tokens":113557,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":17442},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VogHX1aaLmPg7jugHF","type":"assistant","uuid":"905092a4-f47e-447d-b6b8-40ae1c3c5282","timestamp":"2026-02-11T15:12:37.266Z"}
{"parentUuid":"905092a4-f47e-447d-b6b8-40ae1c3c5282","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_0189F2RHcvst2UcPRan6or6k","toolUseID":"toolu_0189F2RHcvst2UcPRan6or6k","timestamp":"2026-02-11T15:12:37.272Z","uuid":"4e9addb3-9323-4518-a66d-fe29e3a4c92c"}
{"parentUuid":"905092a4-f47e-447d-b6b8-40ae1c3c5282","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0189F2RHcvst2UcPRan6or6k","type":"tool_result","content":"  1799→**Order #1015 - Order with discount**\n  1800→\n  1801→| Field | Value |\n  1802→|-------|-------|\n  1803→| customer | customer@acme.test (John Doe) |\n  1804→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1805→| placed_at | now (today) |\n  1806→\n  1807→Lines:\n  1808→\n  1809→| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n  1810→|---------|---------|-----|-----------|-----------|-------------------|\n  1811→| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n  1812→| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n  1813→\n  1814→Totals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n  1815→\n  1816→Payment: stripe, captured, 5447\n  1817→\n  1818→The discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n  1819→\n  1820→---\n  1821→\n  1822→#### Order Summary Table (Acme Fashion)\n  1823→\n  1824→| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n  1825→|-------|----------|--------|-----------|-------------|-------|-------------------|\n  1826→| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n  1827→| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n  1828→| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n  1829→| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n  1830→| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n  1831→| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n  1832→| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n  1833→| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n  1834→| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n  1835→| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n  1836→| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n  1837→| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n  1838→| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n  1839→| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n  1840→| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n  1841→\n  1842→---\n  1843→\n  1844→**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n  1845→\n  1846→| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n  1847→|-------|----------|--------|-----------|-------------|-------|---------|\n  1848→| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n  1849→| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n  1850→| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n  1851→\n  1852→---\n  1853→\n  1854→### 3.14 ThemeSeeder\n  1855→\n  1856→**Acme Fashion:**\n  1857→\n  1858→| Field | Value |\n  1859→|-------|-------|\n  1860→| name | Default Theme |\n  1861→| version | 1.0.0 |\n  1862→| status | published |\n  1863→| published_at | now |\n  1864→\n  1865→**Theme settings (theme_settings row):**\n  1866→\n  1867→| Setting Key | Value |\n  1868→|-------------|-------|\n  1869→| primary_color | #1a1a2e |\n  1870→| secondary_color | #e94560 |\n  1871→| font_family | Inter, sans-serif |\n  1872→| hero_heading | Welcome to Acme Fashion |\n  1873→| hero_subheading | Discover our curated collection of modern essentials |\n  1874→| hero_cta_text | Shop New Arrivals |\n  1875→| hero_cta_link | /collections/new-arrivals |\n  1876→| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n  1877→| footer_text | 2025 Acme Fashion. All rights reserved. |\n  1878→| show_announcement_bar | true |\n  1879→| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n  1880→| products_per_page | 12 |\n  1881→| show_vendor | true |\n  1882→| show_quantity_selector | true |\n  1883→\n  1884→**Acme Electronics:**\n  1885→\n  1886→| Field | Value |\n  1887→|-------|-------|\n  1888→| name | Default Theme |\n  1889→| version | 1.0.0 |\n  1890→| status | published |\n  1891→\n  1892→Theme settings:\n  1893→\n  1894→| Setting Key | Value |\n  1895→|-------------|-------|\n  1896→| primary_color | #0f172a |\n  1897→| secondary_color | #3b82f6 |\n  1898→| font_family | Inter, sans-serif |"}]},"uuid":"41144129-edda-40d4-9ab9-a51606088112","timestamp":"2026-02-11T15:12:37.271Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |","numLines":100,"startLine":1799,"totalLines":2215}},"sourceToolAssistantUUID":"905092a4-f47e-447d-b6b8-40ae1c3c5282"}
{"parentUuid":"41144129-edda-40d4-9ab9-a51606088112","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012fkmLY323FpRkdtzRLaNBf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SoEtCQi2aAGQ9BWJ1mhFPN","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":300,"limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":17442,"cache_read_input_tokens":113557,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":17442},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VogHX1aaLmPg7jugHF","type":"assistant","uuid":"f8c1d980-ade9-42d1-b483-8fbbfd8b2163","timestamp":"2026-02-11T15:12:38.099Z"}
{"parentUuid":"f8c1d980-ade9-42d1-b483-8fbbfd8b2163","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01SoEtCQi2aAGQ9BWJ1mhFPN","toolUseID":"toolu_01SoEtCQi2aAGQ9BWJ1mhFPN","timestamp":"2026-02-11T15:12:38.102Z","uuid":"9f8e6d13-8dce-490c-8dcd-d7a6d28492c4"}
{"parentUuid":"f8c1d980-ade9-42d1-b483-8fbbfd8b2163","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SoEtCQi2aAGQ9BWJ1mhFPN","type":"tool_result","content":"   300→**Model:** CartLine\n   301→\n   302→**Default definition:**\n   303→\n   304→| Field | Generation Rule |\n   305→|-------|----------------|\n   306→| cart_id | Creates a new Cart via its factory |\n   307→| variant_id | Creates a new ProductVariant via its factory |\n   308→| quantity | Random 1-5 |\n   309→| unit_price_amount | Random 999-19999 |\n   310→| line_subtotal_amount | Computed: unit_price_amount * quantity |\n   311→| line_discount_amount | 0 |\n   312→| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n   313→\n   314→**States:** None.\n   315→\n   316→---\n   317→\n   318→### 2.11 CheckoutFactory\n   319→\n   320→**Model:** Checkout\n   321→\n   322→**Default definition:**\n   323→\n   324→| Field | Generation Rule |\n   325→|-------|----------------|\n   326→| store_id | Creates a new Store via its factory |\n   327→| cart_id | Creates a new Cart via its factory |\n   328→| customer_id | null |\n   329→| status | \"started\" |\n   330→| email | Faker safe email |\n   331→| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n   332→| billing_address_json | null |\n   333→| shipping_method_id | null |\n   334→| discount_code | null |\n   335→| tax_provider_snapshot_json | null |\n   336→| totals_json | null |\n   337→| expires_at | 24 hours from now |\n   338→\n   339→**States:**\n   340→\n   341→| State Name | Overrides |\n   342→|-----------|-----------|\n   343→| `completed` | status = \"completed\" |\n   344→| `expired` | status = \"expired\", expires_at = 1 hour ago |\n   345→\n   346→---\n   347→\n   348→### 2.12 OrderFactory\n   349→\n   350→**Model:** Order\n   351→\n   352→**Default definition:**\n   353→\n   354→| Field | Generation Rule |\n   355→|-------|----------------|\n   356→| store_id | Creates a new Store via its factory |\n   357→| customer_id | Creates a new Customer via its factory |\n   358→| order_number | \"#\" + Faker unique integer 1001-9999 |\n   359→| status | \"paid\" |\n   360→| financial_status | \"paid\" |\n   361→| fulfillment_status | \"unfulfilled\" |\n   362→| currency | \"EUR\" |\n   363→| subtotal_amount | 4998 |\n   364→| discount_amount | 0 |\n   365→| shipping_amount | 499 |\n   366→| tax_amount | 798 |\n   367→| total_amount | 5497 |\n   368→| email | Faker safe email |\n   369→| billing_address_json | JSON address object with German Faker data |\n   370→| shipping_address_json | JSON address object with German Faker data |\n   371→| placed_at | Current timestamp |\n   372→\n   373→**States:**\n   374→\n   375→| State Name | Overrides |\n   376→|-----------|-----------|\n   377→| `pending` | status = \"pending\", financial_status = \"pending\" |\n   378→| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n   379→| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n   380→| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n   381→| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n   382→\n   383→---\n   384→\n   385→### 2.13 OrderLineFactory\n   386→\n   387→**Model:** OrderLine\n   388→\n   389→**Default definition:**\n   390→\n   391→| Field | Generation Rule |\n   392→|-------|----------------|\n   393→| order_id | Creates a new Order via its factory |\n   394→| product_id | Creates a new Product via its factory |\n   395→| variant_id | Creates a new ProductVariant via its factory |\n   396→| title_snapshot | Faker 3 words |\n   397→| sku_snapshot | Faker pattern \"SKU-####-???\" |\n   398→| quantity | Random 1-3 |\n   399→| unit_price_amount | Random 999-19999 |"}]},"uuid":"05b9e424-45ae-4487-a1f5-9cd3ab74da04","timestamp":"2026-02-11T15:12:38.102Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |","numLines":100,"startLine":300,"totalLines":2215}},"sourceToolAssistantUUID":"f8c1d980-ade9-42d1-b483-8fbbfd8b2163"}
{"parentUuid":"05b9e424-45ae-4487-a1f5-9cd3ab74da04","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012fkmLY323FpRkdtzRLaNBf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WGL3w1g2bMRhtRvRhPeRRK","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":200,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":17442,"cache_read_input_tokens":113557,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":17442},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VogHX1aaLmPg7jugHF","type":"assistant","uuid":"e0b0c5c6-0f70-4cea-a7ee-a6403866da7a","timestamp":"2026-02-11T15:12:39.067Z"}
{"parentUuid":"e0b0c5c6-0f70-4cea-a7ee-a6403866da7a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01WGL3w1g2bMRhtRvRhPeRRK","toolUseID":"toolu_01WGL3w1g2bMRhtRvRhPeRRK","timestamp":"2026-02-11T15:12:39.070Z","uuid":"2af20c6c-bdcf-4cd6-aa77-729bee38e26e"}
{"parentUuid":"e0b0c5c6-0f70-4cea-a7ee-a6403866da7a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WGL3w1g2bMRhtRvRhPeRRK","type":"tool_result","content":"   200→\n   201→**Expected Results:**\n   202→- Page displays \"T-Shirts\"\n   203→- No JavaScript errors\n   204→\n   205→---\n   206→\n   207→### Test 1.3: Loads a product page\n   208→\n   209→**Preconditions:** Seeded products\n   210→\n   211→**Steps:**\n   212→1. Navigate to `/products/classic-cotton-t-shirt`\n   213→\n   214→**Expected Results:**\n   215→- Page displays \"Classic Cotton T-Shirt\"\n   216→- Page displays \"24.99\"\n   217→- No JavaScript errors\n   218→\n   219→---\n   220→\n   221→### Test 1.4: Loads the cart page\n   222→\n   223→**Preconditions:** None\n   224→\n   225→**Steps:**\n   226→1. Navigate to `/cart`\n   227→\n   228→**Expected Results:**\n   229→- Page displays \"Your Cart\"\n   230→- No JavaScript errors\n   231→\n   232→---\n   233→\n   234→### Test 1.5: Loads the customer login page\n   235→\n   236→**Preconditions:** None\n   237→\n   238→**Steps:**\n   239→1. Navigate to `/account/login`\n   240→\n   241→**Expected Results:**\n   242→- Page displays \"Log in\"\n   243→- No JavaScript errors\n   244→\n   245→---\n   246→\n   247→### Test 1.6: Loads the admin login page\n   248→\n   249→**Preconditions:** None\n   250→\n   251→**Steps:**\n   252→1. Navigate to `/admin/login`\n   253→\n   254→**Expected Results:**\n   255→- Page displays \"Sign in\"\n   256→- No JavaScript errors\n   257→\n   258→---\n   259→\n   260→### Test 1.7: Loads the about page\n   261→\n   262→**Preconditions:** Seeded pages\n   263→\n   264→**Steps:**\n   265→1. Navigate to `/pages/about`\n   266→\n   267→**Expected Results:**\n   268→- Page displays \"About\"\n   269→- No JavaScript errors\n   270→\n   271→---\n   272→\n   273→### Test 1.8: Loads the search page\n   274→\n   275→**Preconditions:** Seeded products\n   276→\n   277→**Steps:**\n   278→1. Navigate to `/search?q=shirt`\n   279→\n   280→**Expected Results:**\n   281→- Page displays \"shirt\"\n   282→- No JavaScript errors\n   283→\n   284→---\n   285→\n   286→### Test 1.9: Loads all collections listing\n   287→\n   288→**Preconditions:** Seeded collections\n   289→\n   290→**Steps:**\n   291→1. Navigate to `/collections`\n   292→\n   293→**Expected Results:**\n   294→- Page displays \"Collections\"\n   295→- No JavaScript errors\n   296→\n   297→---\n   298→\n   299→### Test 1.10: Has no errors on critical pages (batch)\n   300→\n   301→**Preconditions:** All seed data\n   302→\n   303→**Steps:**\n   304→1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n   305→\n   306→**Expected Results:**\n   307→- No JavaScript errors on any of the visited pages\n   308→\n   309→**Total: 10 tests**\n   310→\n   311→---\n   312→\n   313→## Suite 2: Admin Authentication\n   314→\n   315→**File:** `tests/Browser/Admin/AuthenticationTest.php`\n   316→\n   317→**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n   318→\n   319→### Authentication Flow\n   320→\n   321→```mermaid\n   322→stateDiagram-v2\n   323→    [*] --> LoginPage: visit /admin/login\n   324→    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n   325→    LoginPage --> LoginPage: invalid credentials (show error)\n   326→    LoginPage --> LoginPage: empty fields (show validation)\n   327→    Dashboard --> Products: click \"Products\"\n   328→    Dashboard --> Orders: click \"Orders\"\n   329→    Dashboard --> Customers: click \"Customers\"\n   330→    Dashboard --> Discounts: click \"Discounts\"\n   331→    Dashboard --> Settings: click \"Settings\"\n   332→    Dashboard --> Analytics: click \"Analytics\"\n   333→    Dashboard --> Themes: click \"Themes\"\n   334→    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n   335→```\n   336→\n   337→---\n   338→\n   339→### Test 2.1: Can log in as admin\n   340→\n   341→**Preconditions:** Seeded admin user\n   342→\n   343→**Steps:**\n   344→1. Navigate to `/admin/login`\n   345→2. Enter email: `admin@acme.test`\n   346→3. Enter password: `password`\n   347→4. Click the \"Sign in\" button\n   348→\n   349→**Expected Results:**\n   350→- Page displays \"Dashboard\"\n   351→- No JavaScript errors\n   352→\n   353→---\n   354→\n   355→### Test 2.2: Shows error for invalid credentials\n   356→\n   357→**Preconditions:** Seeded admin user\n   358→\n   359→**Steps:**\n   360→1. Navigate to `/admin/login`\n   361→2. Enter email: `admin@acme.test`\n   362→3. Enter password: `wrongpassword`\n   363→4. Click the \"Sign in\" button\n   364→\n   365→**Expected Results:**\n   366→- Page displays \"Invalid credentials\"\n   367→- No JavaScript errors\n   368→\n   369→---\n   370→\n   371→### Test 2.3: Shows error for empty email\n   372→\n   373→**Preconditions:** None\n   374→\n   375→**Steps:**\n   376→1. Navigate to `/admin/login`\n   377→2. Enter password: `password` (leave email empty)\n   378→3. Click the \"Sign in\" button\n   379→\n   380→**Expected Results:**\n   381→- Validation error referencing \"email\" is visible\n   382→- No JavaScript errors\n   383→\n   384→---\n   385→\n   386→### Test 2.4: Shows error for empty password\n   387→\n   388→**Preconditions:** None\n   389→\n   390→**Steps:**\n   391→1. Navigate to `/admin/login`\n   392→2. Enter email: `admin@acme.test` (leave password empty)\n   393→3. Click the \"Sign in\" button\n   394→\n   395→**Expected Results:**\n   396→- Validation error referencing \"password\" is visible\n   397→- No JavaScript errors\n   398→\n   399→---\n   400→\n   401→### Test 2.5: Redirects unauthenticated users to login from dashboard\n   402→\n   403→**Preconditions:** Not logged in\n   404→\n   405→**Steps:**\n   406→1. Navigate to `/admin` (without logging in)\n   407→\n   408→**Expected Results:**\n   409→- Page displays \"Sign in\" (redirected to login form)\n   410→- No JavaScript errors\n   411→\n   412→---\n   413→\n   414→### Test 2.6: Redirects unauthenticated users to login from products\n   415→\n   416→**Preconditions:** Not logged in\n   417→\n   418→**Steps:**\n   419→1. Navigate to `/admin/products` (without logging in)\n   420→\n   421→**Expected Results:**\n   422→- Page displays \"Sign in\" (redirected to login form)\n   423→- No JavaScript errors\n   424→\n   425→---\n   426→\n   427→### Test 2.7: Can log out\n   428→\n   429→**Preconditions:** Seeded admin user\n   430→\n   431→**Steps:**\n   432→1. Navigate to `/admin/login`\n   433→2. Enter email: `admin@acme.test`\n   434→3. Enter password: `password`\n   435→4. Click the \"Sign in\" button\n   436→5. Verify \"Dashboard\" is visible\n   437→6. Click on \"Admin User\" (profile/user menu)\n   438→7. Click \"Logout\"\n   439→\n   440→**Expected Results:**\n   441→- Page displays \"Sign in\" (returned to login form)\n   442→\n   443→---\n   444→\n   445→### Test 2.8: Can navigate through admin sidebar sections\n   446→\n   447→**Preconditions:** Seeded admin user\n   448→\n   449→**Steps:**\n   450→1. Navigate to `/admin/login`\n   451→2. Enter email: `admin@acme.test`\n   452→3. Enter password: `password`\n   453→4. Click the \"Sign in\" button\n   454→5. Click \"Products\" in the sidebar\n   455→6. Verify \"Products\" heading is visible\n   456→7. Click \"Orders\" in the sidebar\n   457→8. Verify \"Orders\" heading is visible\n   458→9. Click \"Customers\" in the sidebar\n   459→10. Verify \"Customers\" heading is visible\n   460→11. Click \"Discounts\" in the sidebar\n   461→12. Verify \"Discounts\" heading is visible\n   462→13. Click \"Settings\" in the sidebar\n   463→14. Verify \"Store Settings\" heading is visible\n   464→\n   465→**Expected Results:**\n   466→- Each section loads without JavaScript errors\n   467→- Each section heading is visible after clicking its sidebar link\n   468→\n   469→---\n   470→\n   471→### Test 2.9: Can navigate to analytics from sidebar\n   472→\n   473→**Preconditions:** Seeded admin user\n   474→\n   475→**Steps:**\n   476→1. Navigate to `/admin/login`\n   477→2. Enter email: `admin@acme.test`\n   478→3. Enter password: `password`\n   479→4. Click the \"Sign in\" button\n   480→5. Click \"Analytics\" in the sidebar\n   481→\n   482→**Expected Results:**\n   483→- Page displays \"Analytics\"\n   484→- No JavaScript errors\n   485→\n   486→---\n   487→\n   488→### Test 2.10: Can navigate to themes from sidebar\n   489→\n   490→**Preconditions:** Seeded admin user\n   491→\n   492→**Steps:**\n   493→1. Navigate to `/admin/login`\n   494→2. Enter email: `admin@acme.test`\n   495→3. Enter password: `password`\n   496→4. Click the \"Sign in\" button\n   497→5. Click \"Themes\" in the sidebar\n   498→\n   499→**Expected Results:**"}]},"uuid":"002a0f09-f1b4-4b19-8e80-35fef4cb0e1e","timestamp":"2026-02-11T15:12:39.069Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**","numLines":300,"startLine":200,"totalLines":2977}},"sourceToolAssistantUUID":"e0b0c5c6-0f70-4cea-a7ee-a6403866da7a"}
{"parentUuid":"002a0f09-f1b4-4b19-8e80-35fef4cb0e1e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012fkmLY323FpRkdtzRLaNBf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01S9ca9cWr9LSXcWoyVCTL1k","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":200,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":17442,"cache_read_input_tokens":113557,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":17442},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VogHX1aaLmPg7jugHF","type":"assistant","uuid":"8c906a51-9277-4dc8-b071-92ce6c169543","timestamp":"2026-02-11T15:12:39.635Z"}
{"parentUuid":"8c906a51-9277-4dc8-b071-92ce6c169543","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01S9ca9cWr9LSXcWoyVCTL1k","toolUseID":"toolu_01S9ca9cWr9LSXcWoyVCTL1k","timestamp":"2026-02-11T15:12:39.638Z","uuid":"a0dfc8fa-94df-4aad-9133-87a9a06b0c70"}
{"parentUuid":"8c906a51-9277-4dc8-b071-92ce6c169543","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01S9ca9cWr9LSXcWoyVCTL1k","type":"tool_result","content":"   200→\n   201→**Rate limiter registration:**\n   202→- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n   203→\n   204→#### Step 1.8: Authorization\n   205→\n   206→**Files to create:**\n   207→\n   208→| Type | Name | Purpose |\n   209→|------|------|---------|\n   210→| Policy | `ProductPolicy` | Product authorization based on store role |\n   211→| Policy | `OrderPolicy` | Order authorization based on store role |\n   212→| Policy | `CollectionPolicy` | Collection authorization based on store role |\n   213→| Policy | `DiscountPolicy` | Discount authorization based on store role |\n   214→| Policy | `CustomerPolicy` | Customer authorization based on store role |\n   215→| Policy | `StorePolicy` | Store authorization based on store role |\n   216→| Policy | `PagePolicy` | Page authorization based on store role |\n   217→| Policy | `ThemePolicy` | Theme authorization based on store role |\n   218→| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n   219→| Policy | `RefundPolicy` | Refund authorization based on store role |\n   220→\n   221→Each policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n   222→\n   223→**Permission Matrix:**\n   224→\n   225→| Permission | Owner | Admin | Staff | Support |\n   226→|------------|:-----:|:-----:|:-----:|:-------:|\n   227→| Manage store settings | Y | Y | N | N |\n   228→| Delete store | Y | N | N | N |\n   229→| Manage staff | Y | Y | N | N |\n   230→| Manage products (CRUD) | Y | Y | Y | N |\n   231→| Delete/archive products | Y | Y | N | N |\n   232→| Manage orders | Y | Y | Y | Read-only |\n   233→| Process refunds | Y | Y | N | N |\n   234→| Create fulfillments | Y | Y | Y | N |\n   235→| Manage discounts | Y | Y | Y | N |\n   236→| Manage themes | Y | Y | N | N |\n   237→| Manage apps | Y | Y | N | N |\n   238→| View analytics | Y | Y | Y | N |\n   239→| Manage shipping/tax | Y | Y | N | N |\n   240→| View customers | Y | Y | Y | Y |\n   241→| Update customers | Y | Y | Y | N |\n   242→\n   243→---\n   244→\n   245→### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n   246→\n   247→**Priority:** HIGH - storefront and orders depend on this.\n   248→\n   249→#### Step 2.1: Migrations (Batch 3-5)\n   250→\n   251→Create in dependency order:\n   252→\n   253→1. `create_products_table` - FK: `store_id`\n   254→2. `create_product_options_table` - FK: `product_id`\n   255→3. `create_product_option_values_table` - FK: `product_option_id`\n   256→4. `create_product_variants_table` - FK: `product_id`\n   257→5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n   258→6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n   259→7. `create_collections_table` - FK: `store_id`\n   260→8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n   261→9. `create_product_media_table` - FK: `product_id`\n   262→\n   263→#### Step 2.2: Models with Relationships\n   264→\n   265→**Files to create:**\n   266→\n   267→| Type | Name | Purpose |\n   268→|------|------|---------|\n   269→| Model (with migration, factory, seeder) | `Product` | Product entity |\n   270→| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n   271→| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n   272→| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n   273→| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n   274→| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n   275→| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n   276→\n   277→**Relationship definitions:**\n   278→\n   279→| Model | Key Relationships |\n   280→|-------|-------------------|\n   281→| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n   282→| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n   283→| `ProductOptionValue` | `belongsTo(ProductOption)` |\n   284→| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n   285→| `InventoryItem` | `belongsTo(ProductVariant)` |\n   286→| `Collection` | `belongsToMany(Product)` via `collection_products` |\n   287→| `ProductMedia` | `belongsTo(Product)` |\n   288→\n   289→**Enums to create:**\n   290→\n   291→| Enum | Values |\n   292→|------|--------|\n   293→| `ProductStatus` | `Draft`, `Active`, `Archived` |\n   294→| `VariantStatus` | `Active`, `Archived` |\n   295→| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n   296→| `MediaType` | `Image`, `Video` |\n   297→| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n   298→| `InventoryPolicy` | `Deny`, `Continue` |\n   299→\n   300→#### Step 2.3: Product Service\n   301→\n   302→**File to create:** `App\\Services\\ProductService`\n   303→\n   304→| Method | Description |\n   305→|--------|-------------|\n   306→| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n   307→| `update(Product $product, array $data): Product` | Updates product |\n   308→| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n   309→| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n   310→\n   311→**File to create:** `App\\Services\\VariantMatrixService`\n   312→\n   313→| Method | Description |\n   314→|--------|-------------|\n   315→| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n   316→\n   317→**File to create:** `App\\Support\\HandleGenerator`\n   318→\n   319→| Method | Description |\n   320→|--------|-------------|\n   321→| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n   322→\n   323→#### Step 2.4: Inventory Service\n   324→\n   325→**File to create:** `App\\Services\\InventoryService`\n   326→\n   327→| Method | Description |\n   328→|--------|-------------|\n   329→| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n   330→| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n   331→| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n   332→| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n   333→| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n   334→\n   335→All operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n   336→\n   337→#### Step 2.5: Media Upload\n   338→\n   339→**File to create:** `App\\Jobs\\ProcessMediaUpload`\n   340→\n   341→Requirements:\n   342→- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n   343→- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n   344→\n   345→Livewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n   346→\n   347→---\n   348→\n   349→### Phase 3: Themes, Pages, Navigation, Storefront Layout\n   350→\n   351→**Priority:** HIGH - storefront rendering depends on this.\n   352→\n   353→#### Step 3.1: Migrations (Batch 3)\n   354→\n   355→1. `create_themes_table` - FK: `store_id`\n   356→2. `create_theme_files_table` - FK: `theme_id`\n   357→3. `create_theme_settings_table` - PK: `theme_id`\n   358→4. `create_pages_table` - FK: `store_id`\n   359→5. `create_navigation_menus_table` - FK: `store_id`\n   360→6. `create_navigation_items_table` - FK: `menu_id`\n   361→\n   362→#### Step 3.2: Models\n   363→\n   364→**Files to create:**\n   365→\n   366→| Type | Name | Purpose |\n   367→|------|------|---------|\n   368→| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n   369→| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n   370→| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n   371→| Model (with migration, factory, seeder) | `Page` | CMS page |\n   372→| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n   373→| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n   374→\n   375→**Relationship definitions:**\n   376→\n   377→| Model | Key Relationships |\n   378→|-------|-------------------|\n   379→| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n   380→| `ThemeFile` | `belongsTo(Theme)` |\n   381→| `ThemeSettings` | `belongsTo(Theme)` |\n   382→| `Page` | `belongsTo(Store)` |\n   383→| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n   384→| `NavigationItem` | `belongsTo(NavigationMenu)` |\n   385→\n   386→**Enums to create:**\n   387→\n   388→| Enum | Values |\n   389→|------|--------|\n   390→| `ThemeStatus` | `Draft`, `Published` |\n   391→| `PageStatus` | `Draft`, `Published`, `Archived` |\n   392→| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n   393→\n   394→#### Step 3.3: Storefront Blade Layout\n   395→\n   396→**Directory structure for `resources/views/storefront/`:**\n   397→\n   398→| Directory | Files |\n   399→|-----------|-------|\n   400→| `layouts/` | `app.blade.php` |\n   401→| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n   402→| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n   403→| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n   404→| `account/orders/` | `index.blade.php`, `show.blade.php` |\n   405→| `account/addresses/` | `index.blade.php` |\n   406→| `errors/` | `404.blade.php`, `503.blade.php` |\n   407→| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n   408→\n   409→Base layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n   410→\n   411→**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n   412→\n   413→#### Step 3.4: Storefront Livewire Components\n   414→\n   415→**Files to create:**\n   416→\n   417→| Component | Purpose |\n   418→|-----------|---------|\n   419→| `Storefront\\Home` | Dynamic sections based on theme settings |\n   420→| `Storefront\\Collections\\Index` | All collections grid |\n   421→| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n   422→| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n   423→| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n   424→| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n   425→| `Storefront\\Search\\Index` | Search results with filters |\n   426→| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n   427→| `Storefront\\Pages\\Show` | Static CMS page rendering |\n   428→\n   429→#### Step 3.5: Navigation Service\n   430→\n   431→**File to create:** `App\\Services\\NavigationService`\n   432→\n   433→| Method | Description |\n   434→|--------|-------------|\n   435→| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n   436→| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n   437→\n   438→Caches per store with 5-minute TTL.\n   439→\n   440→---\n   441→\n   442→### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n   443→\n   444→**Priority:** HIGH - core shopping flow.\n   445→\n   446→#### Step 4.1: Migrations (Batch 4-5)\n   447→\n   448→1. `create_carts_table` - FK: `store_id`, `customer_id`\n   449→2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n   450→3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n   451→4. `create_shipping_zones_table` - FK: `store_id`\n   452→5. `create_shipping_rates_table` - FK: `zone_id`\n   453→6. `create_tax_settings_table` - PK: `store_id`\n   454→7. `create_discounts_table` - FK: `store_id`\n   455→\n   456→#### Step 4.2: Models\n   457→\n   458→**Files to create:**\n   459→\n   460→| Type | Name | Purpose |\n   461→|------|------|---------|\n   462→| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n   463→| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n   464→| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n   465→| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n   466→| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n   467→| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n   468→| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n   469→\n   470→**Relationship definitions:**\n   471→\n   472→| Model | Key Relationships |\n   473→|-------|-------------------|\n   474→| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n   475→| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n   476→| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n   477→| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n   478→| `ShippingRate` | `belongsTo(ShippingZone)` |\n   479→| `TaxSettings` | `belongsTo(Store)` |\n   480→| `Discount` | `belongsTo(Store)` |\n   481→\n   482→**Enums to create:**\n   483→\n   484→| Enum | Values |\n   485→|------|--------|\n   486→| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n   487→| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n   488→| `DiscountType` | `Code`, `Automatic` |\n   489→| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n   490→| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n   491→| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n   492→| `TaxMode` | `Manual`, `Provider` |\n   493→\n   494→#### Step 4.3: Cart Service\n   495→\n   496→**File to create:** `App\\Services\\CartService`\n   497→\n   498→| Method | Description |\n   499→|--------|-------------|"}]},"uuid":"7845aab3-0ff4-4b2a-b3a8-ba6875adef17","timestamp":"2026-02-11T15:12:39.638Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|","numLines":300,"startLine":200,"totalLines":2027}},"sourceToolAssistantUUID":"8c906a51-9277-4dc8-b071-92ce6c169543"}
{"parentUuid":"7845aab3-0ff4-4b2a-b3a8-ba6875adef17","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WcwLGxZumdbvKmeAQyPrJf","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a comprehensive understanding of all the spec files. Let me read the Phase 5 section of the implementation roadmap which specifically covers payments and orders.","signature":"EtUCCkYICxgCKkALCaHBTyr+KE+jBTVvYco4l+ew4h896AjMKvThKJ3AtM8ojOe3x8W1ZGpRzY1NCQsYpTq1VKz014mkErVqp1lIEgztffOfDbyQH9mjZhQaDJOMtB5pjsQ9kahORCIwuiMJ4Z+DnLSEms3R1N+RLZPdJAeT/JzAVEPSmuzHTICgGBHhkmVxAr6SlyN4NOR4KrwBwPlJdowqNoa5v37VcwxCt3v6ncUofJTe3qflTEtTONpYjdM7C6nHymPZsoN9karriVVVMHyJM9oTIenmGOhBA6w/LkdGKnGanqQ7QHMO+6GxK0ZGLiev/ksUDhIxXooHgMHgxrEZbUHG8SsHf0dtmn5t4nJy02BLJDqOxx+7id2PU6wnyJlX2XiAVqbQ99qseuKSDV31LXh07g6ZA4+8gG4mEyHmF4lvMAHOMFsXL2/qm+UZwA3TCKlibpgYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11294,"cache_read_input_tokens":130999,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11294},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VpGZXr4S1YGcQDZTtA","type":"assistant","uuid":"6e28bdf3-1fb4-4d25-bea5-4b92e0f83ab6","timestamp":"2026-02-11T15:12:43.024Z"}
{"parentUuid":"6e28bdf3-1fb4-4d25-bea5-4b92e0f83ab6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WcwLGxZumdbvKmeAQyPrJf","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the Phase 5 payment/order section and the checkout E2E tests."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11294,"cache_read_input_tokens":130999,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11294},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VpGZXr4S1YGcQDZTtA","type":"assistant","uuid":"365f2a9a-8c59-4a9f-b63a-1dcdcda38dc9","timestamp":"2026-02-11T15:12:43.590Z"}
{"parentUuid":"365f2a9a-8c59-4a9f-b63a-1dcdcda38dc9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WcwLGxZumdbvKmeAQyPrJf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YEEJyxu6iWDqFh6Af23sEm","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":500,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11294,"cache_read_input_tokens":130999,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11294},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VpGZXr4S1YGcQDZTtA","type":"assistant","uuid":"bafd73b1-22a9-4c39-af4a-b9385644c1dc","timestamp":"2026-02-11T15:12:45.101Z"}
{"parentUuid":"bafd73b1-22a9-4c39-af4a-b9385644c1dc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01YEEJyxu6iWDqFh6Af23sEm","toolUseID":"toolu_01YEEJyxu6iWDqFh6Af23sEm","timestamp":"2026-02-11T15:12:45.103Z","uuid":"c400ad5a-5ecb-4146-afcd-1ad6d9142af4"}
{"parentUuid":"bafd73b1-22a9-4c39-af4a-b9385644c1dc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YEEJyxu6iWDqFh6Af23sEm","type":"tool_result","content":"   500→| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n   501→| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n   502→| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n   503→| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n   504→| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n   505→| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n   506→\n   507→All mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n   508→\n   509→#### Step 4.4: Discount Service\n   510→\n   511→**File to create:** `App\\Services\\DiscountService`\n   512→\n   513→| Method | Description |\n   514→|--------|-------------|\n   515→| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n   516→| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n   517→\n   518→Case-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n   519→\n   520→#### Step 4.5: Shipping Calculator\n   521→\n   522→**File to create:** `App\\Services\\ShippingCalculator`\n   523→\n   524→| Method | Description |\n   525→|--------|-------------|\n   526→| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n   527→| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n   528→\n   529→Zone matching: checks `countries_json` for country code, then `regions_json` for region code.\n   530→\n   531→#### Step 4.6: Tax Calculator\n   532→\n   533→**File to create:** `App\\Services\\TaxCalculator`\n   534→\n   535→| Method | Description |\n   536→|--------|-------------|\n   537→| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n   538→| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n   539→| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n   540→\n   541→All math uses integers. Rates stored in basis points (1900 = 19%).\n   542→\n   543→#### Step 4.7: Pricing Engine\n   544→\n   545→**File to create:** `App\\Services\\PricingEngine`\n   546→\n   547→Method: `calculate(Checkout $checkout): PricingResult`\n   548→\n   549→**Pipeline steps:**\n   550→1. Line subtotals: `unit_price * quantity` per line\n   551→2. Cart subtotal: sum of line subtotals\n   552→3. Discount: apply rules, allocate proportionally\n   553→4. Discounted subtotal: subtotal minus discount\n   554→5. Shipping: based on selected method/rate\n   555→6. Tax: on discounted amounts (plus shipping if taxable)\n   556→7. Total: discounted_subtotal + shipping + tax_total\n   557→\n   558→Result stored in `checkouts.totals_json` as snapshot.\n   559→\n   560→**Value objects to create:**\n   561→\n   562→| Type | Name | Fields |\n   563→|------|------|--------|\n   564→| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n   565→| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n   566→\n   567→#### Step 4.8: Checkout State Machine\n   568→\n   569→**File to create:** `App\\Services\\CheckoutService`\n   570→\n   571→| Transition | Method | Requirements |\n   572→|------------|--------|-------------|\n   573→| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n   574→| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n   575→| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n   576→| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n   577→| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n   578→\n   579→**Files to create:**\n   580→\n   581→| Type | Name | Purpose |\n   582→|------|------|---------|\n   583→| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n   584→| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n   585→\n   586→#### Step 4.9: Storefront Cart/Checkout UI\n   587→\n   588→**Files to create:**\n   589→\n   590→| Component | Description |\n   591→|-----------|-------------|\n   592→| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n   593→| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n   594→| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n   595→| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n   596→\n   597→---\n   598→\n   599→### Phase 5: Payments, Orders, Fulfillment\n   600→\n   601→**Priority:** HIGH - completes the purchase flow.\n   602→\n   603→#### Step 5.1: Migrations (Batch 5-7)\n   604→\n   605→1. `create_customers_table` - FK: `store_id`\n   606→2. `create_customer_addresses_table` - FK: `customer_id`\n   607→3. `create_orders_table` - FK: `store_id`, `customer_id`\n   608→4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n   609→5. `create_payments_table` - FK: `order_id`\n   610→6. `create_refunds_table` - FK: `order_id`, `payment_id`\n   611→7. `create_fulfillments_table` - FK: `order_id`\n   612→8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n   613→\n   614→#### Step 5.2: Models\n   615→\n   616→**Files to create:**\n   617→\n   618→| Type | Name | Purpose |\n   619→|------|------|---------|\n   620→| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n   621→| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n   622→| Model (with migration, factory, seeder) | `Order` | Order entity |\n   623→| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n   624→| Model (with migration, factory, seeder) | `Payment` | Payment record |\n   625→| Model (with migration, factory, seeder) | `Refund` | Refund record |\n   626→| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n   627→| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n   628→\n   629→**Relationship definitions:**\n   630→\n   631→| Model | Key Relationships |\n   632→|-------|-------------------|\n   633→| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n   634→| `CustomerAddress` | `belongsTo(Customer)` |\n   635→| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n   636→| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n   637→| `Payment` | `belongsTo(Order)` |\n   638→| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n   639→| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n   640→| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n   641→\n   642→**Enums to create:**\n   643→\n   644→| Enum | Values |\n   645→|------|--------|\n   646→| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n   647→| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n   648→| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n   649→| `PaymentProvider` | `Stripe`, `Paypal` |\n   650→| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |\n   651→| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n   652→| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n   653→\n   654→#### Step 5.3: Payment Service\n   655→\n   656→**Files to create:**\n   657→\n   658→| Type | Name | Purpose |\n   659→|------|------|---------|\n   660→| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n   661→| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n   662→| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n   663→\n   664→**PaymentProvider interface methods:**\n   665→\n   666→| Method | Description |\n   667→|--------|-------------|\n   668→| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n   669→| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n   670→| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n   671→\n   672→**StripePaymentProvider requirements:**\n   673→- Creates Stripe Checkout Sessions in test mode\n   674→- Webhook handler with HMAC signature verification\n   675→- Idempotent event processing (checks `provider_payment_id` uniqueness)\n   676→\n   677→**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.\n   678→\n   679→#### Step 5.4: Order Service\n   680→\n   681→**File to create:** `App\\Services\\OrderService`\n   682→\n   683→| Method | Description |\n   684→|--------|-------------|\n   685→| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n   686→| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n   687→| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n   688→\n   689→Order lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n   690→\n   691→#### Step 5.5: Refund Service\n   692→\n   693→**File to create:** `App\\Services\\RefundService`\n   694→\n   695→| Method | Description |\n   696→|--------|-------------|\n   697→| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n   698→\n   699→- Partial refunds: `financial_status` -> `partially_refunded`\n   700→- Full refunds: `financial_status` -> `refunded`\n   701→- Restock flag: calls `InventoryService::restock()` for each line\n   702→\n   703→#### Step 5.6: Fulfillment Service\n   704→\n   705→**File to create:** `App\\Services\\FulfillmentService`\n   706→\n   707→| Method | Description |\n   708→|--------|-------------|\n   709→| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n   710→| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n   711→| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n   712→\n   713→Updates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n   714→\n   715→**Events to create:**\n   716→\n   717→| Type | Name | Purpose |\n   718→|------|------|---------|\n   719→| Event | `OrderCreated` | Dispatched when order is created from checkout |\n   720→| Event | `OrderPaid` | Dispatched when payment is captured |\n   721→| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n   722→| Event | `OrderCancelled` | Dispatched when order is cancelled |\n   723→| Event | `OrderRefunded` | Dispatched when refund is processed |\n   724→\n   725→---\n   726→\n   727→### Phase 6: Customer Accounts\n   728→\n   729→**Priority:** MEDIUM - enhances the shopping experience.\n   730→\n   731→#### Step 6.1: Customer Auth\n   732→\n   733→- `Customer` model with custom `customer` guard (already configured in Phase 1)\n   734→- Custom `CustomerUserProvider` - scopes queries by `store_id`\n   735→- Customer email unique per store, not globally\n   736→\n   737→#### Step 6.2: Customer Account Pages (Livewire)\n   738→\n   739→**Files to create:**\n   740→\n   741→| Component | Route | Description |\n   742→|-----------|-------|-------------|\n   743→| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n   744→| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n   745→| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n   746→| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n   747→| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n   748→| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n   749→\n   750→Logout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n   751→\n   752→---\n   753→\n   754→### Phase 7: Admin Panel\n   755→\n   756→**Priority:** MEDIUM - merchant management interface.\n   757→\n   758→#### Step 7.1: Admin Layout\n   759→\n   760→**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n   761→\n   762→**Components to create:**\n   763→\n   764→| Component | Description |\n   765→|-----------|-------------|\n   766→| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n   767→| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n   768→| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n   769→\n   770→Toast notifications via Livewire events.\n   771→\n   772→#### Step 7.2: Dashboard\n   773→\n   774→**File to create:** `Admin\\Dashboard` Livewire component\n   775→\n   776→Requirements:\n   777→- KPI tiles: total sales (period), order count, average order value, conversion rate\n   778→- Orders chart (last 30 days line chart)\n   779→- Recent orders table (last 10)\n   780→- Date range filtering via Livewire properties\n   781→\n   782→#### Step 7.3: Product Management\n   783→\n   784→**Files to create:**\n   785→\n   786→| Component | Description |\n   787→|-----------|-------------|\n   788→| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n   789→| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n   790→\n   791→> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n   792→\n   793→Variants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n   794→\n   795→Media upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n   796→\n   797→#### Step 7.4: Order Management\n   798→\n   799→**Files to create:**"}]},"uuid":"1acec808-e5b9-4310-9d49-659ed3ffda7e","timestamp":"2026-02-11T15:12:45.103Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentProvider` | `Stripe`, `Paypal` |\n| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n\n**StripePaymentProvider requirements:**\n- Creates Stripe Checkout Sessions in test mode\n- Webhook handler with HMAC signature verification\n- Idempotent event processing (checks `provider_payment_id` uniqueness)\n\n**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**","numLines":300,"startLine":500,"totalLines":2027}},"sourceToolAssistantUUID":"bafd73b1-22a9-4c39-af4a-b9385644c1dc"}
{"parentUuid":"1acec808-e5b9-4310-9d49-659ed3ffda7e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WcwLGxZumdbvKmeAQyPrJf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SLtyqsUR9yLeXxnXv77i1Z","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":500,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":11294,"cache_read_input_tokens":130999,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11294},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VpGZXr4S1YGcQDZTtA","type":"assistant","uuid":"21044390-0c6b-4568-b0c7-9a8984c69d28","timestamp":"2026-02-11T15:12:46.006Z"}
{"parentUuid":"21044390-0c6b-4568-b0c7-9a8984c69d28","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01SLtyqsUR9yLeXxnXv77i1Z","toolUseID":"toolu_01SLtyqsUR9yLeXxnXv77i1Z","timestamp":"2026-02-11T15:12:46.008Z","uuid":"73846562-c1f0-42f9-9d45-80c59c7d5667"}
{"parentUuid":"21044390-0c6b-4568-b0c7-9a8984c69d28","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SLtyqsUR9yLeXxnXv77i1Z","type":"tool_result","content":"   500→- Page displays \"Themes\"\n   501→- No JavaScript errors\n   502→\n   503→**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n   504→\n   505→**Total: 10 tests**\n   506→\n   507→---\n   508→\n   509→## Suite 3: Admin Product Management\n   510→\n   511→**File:** `tests/Browser/Admin/ProductManagementTest.php`\n   512→\n   513→**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n   514→\n   515→### Product Lifecycle States\n   516→\n   517→```mermaid\n   518→stateDiagram-v2\n   519→    [*] --> Draft: create product (default)\n   520→    Draft --> Active: set status to Active + Save\n   521→    Active --> Archived: set status to Archived + Save\n   522→    Draft --> Archived: set status to Archived + Save\n   523→    Archived --> Active: set status to Active + Save\n   524→    Active --> Draft: set status to Draft + Save\n   525→```\n   526→\n   527→---\n   528→\n   529→### Test 3.1: Shows the product list with seeded products\n   530→\n   531→**Preconditions:** Logged in as admin, seeded products\n   532→\n   533→**Steps:**\n   534→1. Navigate to `/admin/login`\n   535→2. Enter email: `admin@acme.test`\n   536→3. Enter password: `password`\n   537→4. Click the \"Sign in\" button\n   538→5. Click \"Products\" in the sidebar\n   539→\n   540→**Expected Results:**\n   541→- Page displays \"Classic Cotton T-Shirt\"\n   542→- Page displays \"Premium Slim Fit Jeans\"\n   543→- No JavaScript errors\n   544→\n   545→---\n   546→\n   547→### Test 3.2: Can create a new product\n   548→\n   549→**Preconditions:** Logged in as admin\n   550→\n   551→**Steps:**\n   552→1. Navigate to `/admin/login`\n   553→2. Log in as admin (email: `admin@acme.test`, password: `password`)\n   554→3. Click \"Products\" in the sidebar\n   555→4. Click \"Add product\"\n   556→5. Enter title: `Test Product Created by E2E`\n   557→6. Enter description: `This product was created by the E2E test suite.`\n   558→7. Enter vendor: `Test Vendor`\n   559→8. Enter product type: `T-Shirts`\n   560→9. Enter variant price (variants.0.price): `29.99`\n   561→10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n   562→11. Enter variant quantity (variants.0.quantity): `50`\n   563→12. Click \"Save\"\n   564→13. Click \"Products\" to return to the product list\n   565→\n   566→**Expected Results:**\n   567→- After step 12: page displays \"Product saved\", no JavaScript errors\n   568→- After step 13: page displays \"Test Product Created by E2E\" in the list\n   569→\n   570→---\n   571→\n   572→### Test 3.3: Can edit an existing product title\n   573→\n   574→**Preconditions:** Logged in as admin, seeded products\n   575→\n   576→**Steps:**\n   577→1. Navigate to `/admin/login`\n   578→2. Log in as admin\n   579→3. Click \"Products\" in the sidebar\n   580→4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n   581→5. Click \"Classic Cotton T-Shirt\" to open the edit form\n   582→6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n   583→7. Click \"Save\"\n   584→8. Click \"Products\" to return to the product list\n   585→\n   586→**Expected Results:**\n   587→- After step 7: page displays \"Product saved\", no JavaScript errors\n   588→- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n   589→\n   590→---\n   591→\n   592→### Test 3.4: Can archive a product\n   593→\n   594→**Preconditions:** Logged in as admin\n   595→\n   596→**Steps:**\n   597→1. Navigate to `/admin/login`\n   598→2. Log in as admin\n   599→3. Click \"Products\" in the sidebar\n   600→4. Click \"Add product\"\n   601→5. Enter title: `Product To Archive`\n   602→6. Enter variant price: `19.99`\n   603→7. Enter variant SKU: `E2E-ARCHIVE-001`\n   604→8. Enter variant quantity: `10`\n   605→9. Click \"Save\"\n   606→10. Verify \"Product saved\" is displayed\n   607→11. Click \"Products\" to return to the product list\n   608→12. Click \"Product To Archive\" to open the edit form\n   609→13. Change status to \"Archived\" (click the Archived option)\n   610→14. Click \"Save\"\n   611→15. Click \"Products\" to return to the product list\n   612→\n   613→**Expected Results:**\n   614→- After step 9: page displays \"Product saved\"\n   615→- After step 14: page displays \"Product saved\"\n   616→- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n   617→\n   618→---\n   619→\n   620→### Test 3.5: Shows draft products only in admin, not storefront\n   621→\n   622→**Preconditions:** Logged in as admin, Product #15 exists with draft status\n   623→\n   624→**Steps:**\n   625→1. Navigate to `/admin/login`\n   626→2. Log in as admin\n   627→3. Click \"Products\" in the sidebar\n   628→4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n   629→5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n   630→6. Verify the draft product does NOT appear in the collection listing\n   631→7. Navigate to `/search?q=draft`\n   632→8. Verify the draft product does NOT appear in search results\n   633→\n   634→**Expected Results:**\n   635→- Step 4: draft product visible in admin, no JavaScript errors\n   636→- Step 6: draft product absent from storefront collection, no JavaScript errors\n   637→- Step 8: draft product absent from search results, no JavaScript errors\n   638→\n   639→---\n   640→\n   641→### Test 3.6: Can search products in admin\n   642→\n   643→**Preconditions:** Logged in as admin, seeded products\n   644→\n   645→**Steps:**\n   646→1. Navigate to `/admin/login`\n   647→2. Log in as admin\n   648→3. Click \"Products\" in the sidebar\n   649→4. Enter \"Cotton\" in the search input field\n   650→\n   651→**Expected Results:**\n   652→- Page displays \"Classic Cotton T-Shirt\" in filtered results\n   653→- No JavaScript errors\n   654→\n   655→---\n   656→\n   657→### Test 3.7: Can filter products by status in admin\n   658→\n   659→**Preconditions:** Logged in as admin, seeded products including drafts\n   660→\n   661→**Steps:**\n   662→1. Navigate to `/admin/login`\n   663→2. Log in as admin\n   664→3. Click \"Products\" in the sidebar\n   665→4. Click the \"Draft\" status filter tab\n   666→5. Verify only draft products are visible\n   667→6. Click the \"Active\" status filter tab\n   668→\n   669→**Expected Results:**\n   670→- After step 4: only draft products are visible, no JavaScript errors\n   671→- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n   672→\n   673→**Total: 7 tests**\n   674→\n   675→---\n   676→\n   677→## Suite 4: Admin Order Management\n   678→\n   679→**File:** `tests/Browser/Admin/OrderManagementTest.php`\n   680→\n   681→**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n   682→\n   683→### Order State Transitions (via admin actions)\n   684→\n   685→```mermaid\n   686→stateDiagram-v2\n   687→    state financial {\n   688→        pending_f --> paid: payment captured\n   689→        paid --> partially_refunded: partial refund\n   690→        paid --> refunded: full refund\n   691→    }\n   692→    state fulfillment {\n   693→        unfulfilled --> partial: partial fulfillment\n   694→        unfulfilled --> fulfilled: full fulfillment\n   695→        partial --> fulfilled: remaining items fulfilled\n   696→    }\n   697→```\n   698→\n   699→---\n   700→\n   701→### Test 4.1: Shows the order list with seeded orders\n   702→\n   703→**Preconditions:** Logged in as admin, seeded orders\n   704→\n   705→**Steps:**\n   706→1. Navigate to `/admin/login`\n   707→2. Log in as admin\n   708→3. Click \"Orders\" in the sidebar\n   709→\n   710→**Expected Results:**\n   711→- Page displays \"#1001\"\n   712→- No JavaScript errors\n   713→\n   714→---\n   715→\n   716→### Test 4.2: Can filter orders by status\n   717→\n   718→**Preconditions:** Logged in as admin, seeded orders\n   719→\n   720→**Steps:**\n   721→1. Navigate to `/admin/login`\n   722→2. Log in as admin\n   723→3. Click \"Orders\" in the sidebar\n   724→4. Click the \"Paid\" status filter/tab\n   725→5. Verify \"#1001\" is visible\n   726→6. Click the \"Fulfilled\" status filter/tab\n   727→7. Click the \"All\" tab to reset the filter\n   728→\n   729→**Expected Results:**\n   730→- After step 4: \"#1001\" is visible, no JavaScript errors\n   731→- After step 6: filtered results shown, no JavaScript errors\n   732→- After step 7: full list shown, no JavaScript errors\n   733→\n   734→---\n   735→\n   736→### Test 4.3: Shows order detail with line items and totals\n   737→\n   738→**Preconditions:** Logged in as admin, Order #1001 exists\n   739→\n   740→**Steps:**\n   741→1. Navigate to `/admin/login`\n   742→2. Log in as admin\n   743→3. Click \"Orders\" in the sidebar\n   744→4. Click \"#1001\" to open the order detail\n   745→\n   746→**Expected Results:**\n   747→- Page displays \"#1001\"\n   748→- Page displays \"Paid\" (financial status)\n   749→- Page displays \"Unfulfilled\" (fulfillment status)\n   750→- Page displays \"Classic Cotton T-Shirt\" (line item)\n   751→- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n   752→- No JavaScript errors\n   753→\n   754→---\n   755→\n   756→### Test 4.4: Shows order timeline events\n   757→\n   758→**Preconditions:** Logged in as admin, Order #1001 exists\n   759→\n   760→**Steps:**\n   761→1. Navigate to `/admin/login`\n   762→2. Log in as admin\n   763→3. Click \"Orders\" in the sidebar\n   764→4. Click \"#1001\" to open the order detail\n   765→\n   766→**Expected Results:**\n   767→- Page displays \"Timeline\" section\n   768→- Timeline shows at least the order creation event\n   769→- No JavaScript errors\n   770→\n   771→---\n   772→\n   773→### Test 4.5: Can create a fulfillment\n   774→\n   775→**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n   776→\n   777→**Steps:**\n   778→1. Navigate to `/admin/login`\n   779→2. Log in as admin\n   780→3. Click \"Orders\" in the sidebar\n   781→4. Click \"#1001\" to open the order detail\n   782→5. Click \"Create fulfillment\"\n   783→6. Enter tracking company: `DHL`\n   784→7. Enter tracking number: `DHL123456789`\n   785→8. Click \"Fulfill items\"\n   786→\n   787→**Expected Results:**\n   788→- Page displays \"Fulfillment created\"\n   789→- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n   790→- No JavaScript errors\n   791→\n   792→---\n   793→\n   794→### Test 4.6: Can process a refund\n   795→\n   796→**Preconditions:** Logged in as admin, Order #1001 is paid\n   797→\n   798→**Steps:**\n   799→1. Navigate to `/admin/login`"}]},"uuid":"f25b7366-6cc3-4be7-97e5-12ce9b55ae28","timestamp":"2026-02-11T15:12:46.008Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`","numLines":300,"startLine":500,"totalLines":2977}},"sourceToolAssistantUUID":"21044390-0c6b-4568-b0c7-9a8984c69d28"}
{"parentUuid":"f25b7366-6cc3-4be7-97e5-12ce9b55ae28","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_0136zh4tJQzjHRc51WmAFxDk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a comprehensive understanding of all the spec files. Let me also check the checkout E2E test section.","signature":"EpkCCkYICxgCKkAOVK7C0WnD/MGQj56j4SlzmIi7XOQziijaTVlCq2WsvIHzbOG4YGvacl5TxQ61UIHwVtkdSOu4KckXpoA6U4u2EgxFU+dExvFi/DRkGQAaDFownYvQxNKkG98ofyIwbFXD9zCY4M4pN1D18VKX5r0mrySmiJaq6SCeMbvG257JrPvJYPbZiWNfTybRFqy0KoABvO/zIhniSTWwVYxcWEF01eV4uSfPithO915IIDaNx9WuzlCgEwIiNXgM06eNzohRvspk3N1q3nt2ZYVzr7vCTzWa1N/97z2JI3pKFnz69ZhNXpB39WFVxEL6D6X+zqg6SodYOSN0n87Ew/PHjYm92IRLlgugWxBWl8JoJbX1fSEYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":8878,"cache_read_input_tokens":142293,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8878},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VpjsM7rtbFjCJ35WT1","type":"assistant","uuid":"7eed5639-3348-43d9-82c9-577f39303c17","timestamp":"2026-02-11T15:12:48.861Z"}
{"parentUuid":"7eed5639-3348-43d9-82c9-577f39303c17","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_0136zh4tJQzjHRc51WmAFxDk","type":"message","role":"assistant","content":[{"type":"text","text":"Now I have a comprehensive understanding of all spec files. Let me start making the edits. I'll work through each file systematically, starting with the foundational changes and progressing to the dependent ones. Let me update the task tracking and begin editing."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":8878,"cache_read_input_tokens":142293,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8878},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VpjsM7rtbFjCJ35WT1","type":"assistant","uuid":"63d61e07-968c-4865-b189-67ecbd1f1a54","timestamp":"2026-02-11T15:12:50.263Z"}
{"parentUuid":"63d61e07-968c-4865-b189-67ecbd1f1a54","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_0136zh4tJQzjHRc51WmAFxDk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KdsWHXAWf16ybrTfGojgQq","name":"TaskUpdate","input":{"taskId":"1","status":"in_progress","activeForm":"Updating database schema spec"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":8878,"cache_read_input_tokens":142293,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8878},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VpjsM7rtbFjCJ35WT1","type":"assistant","uuid":"a92f59d0-8904-4696-978b-90a044e8f368","timestamp":"2026-02-11T15:12:51.359Z"}
{"parentUuid":"a92f59d0-8904-4696-978b-90a044e8f368","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KdsWHXAWf16ybrTfGojgQq","type":"tool_result","content":"Updated task #1 status"}]},"uuid":"75860443-fd1b-4b62-befc-1e3a9043033f","timestamp":"2026-02-11T15:12:51.362Z","toolUseResult":{"success":true,"taskId":"1","updatedFields":["status"],"statusChange":{"from":"pending","to":"in_progress"}},"sourceToolAssistantUUID":"a92f59d0-8904-4696-978b-90a044e8f368"}
{"parentUuid":"75860443-fd1b-4b62-befc-1e3a9043033f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Q2eevwY9w88VvpHo5Wy6nn","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me make all the edits. I'll start with the three core spec files in parallel.\n\n**1. Database Schema (01)** - Add `payment_method` to checkouts/orders, change payments.provider CHECK:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":204,"cache_read_input_tokens":151171,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":204},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vq8gYNpgwYDTuud8hC","type":"assistant","uuid":"4e95ec93-024f-41cf-9fab-e5d2205981e4","timestamp":"2026-02-11T15:12:55.450Z"}
{"type":"file-history-snapshot","messageId":"cb8ae716-f374-44c0-9696-eeb83f8835eb","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"4e95ec93-024f-41cf-9fab-e5d2205981e4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Q2eevwY9w88VvpHo5Wy6nn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016vXp8Hx4GGdkUWSy5KQz7Q","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","old_string":"| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| email | TEXT | Yes | NULL | - | Contact email |","new_string":"| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":204,"cache_read_input_tokens":151171,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":204},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vq8gYNpgwYDTuud8hC","type":"assistant","uuid":"cb8ae716-f374-44c0-9696-eeb83f8835eb","timestamp":"2026-02-11T15:12:59.192Z"}
{"parentUuid":"cb8ae716-f374-44c0-9696-eeb83f8835eb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_016vXp8Hx4GGdkUWSy5KQz7Q","toolUseID":"toolu_016vXp8Hx4GGdkUWSy5KQz7Q","timestamp":"2026-02-11T15:12:59.198Z","uuid":"d9d12dbb-1343-43c0-9686-93703074eff5"}
{"parentUuid":"cb8ae716-f374-44c0-9696-eeb83f8835eb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016vXp8Hx4GGdkUWSy5KQz7Q","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md has been updated successfully."}]},"uuid":"88e9721d-a16e-4aa7-bdca-c4a8d7c03164","timestamp":"2026-02-11T15:12:59.198Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","oldString":"| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| email | TEXT | Yes | NULL | - | Contact email |","newString":"| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |","originalFile":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","structuredPatch":[{"oldStart":585,"oldLines":6,"newStart":585,"newLines":7,"lines":[" | cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |"," | customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |"," | status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |","+| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |"," | email | TEXT | Yes | NULL | - | Contact email |"," | shipping_address_json | TEXT | Yes | NULL | - | JSON address object |"," | billing_address_json | TEXT | Yes | NULL | - | JSON address object |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"cb8ae716-f374-44c0-9696-eeb83f8835eb"}
{"parentUuid":"88e9721d-a16e-4aa7-bdca-c4a8d7c03164","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Q2eevwY9w88VvpHo5Wy6nn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LuLoBU9S8cgFDLfnSQri81","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","old_string":"| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |","new_string":"| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":204,"cache_read_input_tokens":151171,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":204},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vq8gYNpgwYDTuud8hC","type":"assistant","uuid":"49481671-3f4e-4cee-9381-87232930023e","timestamp":"2026-02-11T15:13:03.138Z"}
{"parentUuid":"49481671-3f4e-4cee-9381-87232930023e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01LuLoBU9S8cgFDLfnSQri81","toolUseID":"toolu_01LuLoBU9S8cgFDLfnSQri81","timestamp":"2026-02-11T15:13:03.144Z","uuid":"20bf78a1-1a4a-4843-96f7-585fc535a840"}
{"parentUuid":"49481671-3f4e-4cee-9381-87232930023e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LuLoBU9S8cgFDLfnSQri81","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md has been updated successfully."}]},"uuid":"4af3feef-0471-4c26-9c88-d16f8e6c001a","timestamp":"2026-02-11T15:13:03.143Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","oldString":"| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |","newString":"| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |","originalFile":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","structuredPatch":[{"oldStart":758,"oldLines":6,"newStart":758,"newLines":7,"lines":[" | store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |"," | customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |"," | order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |","+| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |"," | status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |"," | financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |"," | fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"49481671-3f4e-4cee-9381-87232930023e"}
{"parentUuid":"4af3feef-0471-4c26-9c88-d16f8e6c001a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Q2eevwY9w88VvpHo5Wy6nn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Eoeq6UXj4vVrRFLhrnaZWc","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","old_string":"| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |","new_string":"| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":204,"cache_read_input_tokens":151171,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":204},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vq8gYNpgwYDTuud8hC","type":"assistant","uuid":"05419e2a-63da-4baa-a9b6-06bf40fc4f4c","timestamp":"2026-02-11T15:13:05.989Z"}
{"parentUuid":"05419e2a-63da-4baa-a9b6-06bf40fc4f4c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Eoeq6UXj4vVrRFLhrnaZWc","toolUseID":"toolu_01Eoeq6UXj4vVrRFLhrnaZWc","timestamp":"2026-02-11T15:13:05.994Z","uuid":"bd1b9b01-2163-4a9d-ad09-fe190545d282"}
{"parentUuid":"05419e2a-63da-4baa-a9b6-06bf40fc4f4c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Eoeq6UXj4vVrRFLhrnaZWc","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md has been updated successfully."}]},"uuid":"1dba19d5-a7b6-4628-af31-b3deda10f7e0","timestamp":"2026-02-11T15:13:05.993Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","oldString":"| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |","newString":"| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |","originalFile":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |\n| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","structuredPatch":[{"oldStart":823,"oldLines":8,"newStart":823,"newLines":9,"lines":[" |---|---|---|---|---|---|"," | id | INTEGER | No | autoincrement | PRIMARY KEY | |"," | order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |","-| provider | TEXT | No | - | CHECK IN ('stripe', 'paypal') | Payment gateway |","-| provider_payment_id | TEXT | Yes | NULL | - | External payment ID |","+| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |","+| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |","+| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |"," | status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |"," | amount | INTEGER | No | 0 | - | Payment amount in minor units |"," | currency | TEXT | No | 'USD' | - | ISO 4217 currency code |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"05419e2a-63da-4baa-a9b6-06bf40fc4f4c"}
{"parentUuid":"1dba19d5-a7b6-4628-af31-b3deda10f7e0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01VbPUmUQwsYeebBM4u6yK54","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the payments table notes and index:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1201,"cache_read_input_tokens":151375,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1201},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VrDG9x3xTTLGrUsg7T","type":"assistant","uuid":"1de2d12a-77c0-4fdc-b8fe-9b189b617b8a","timestamp":"2026-02-11T15:13:08.869Z"}
{"parentUuid":"1de2d12a-77c0-4fdc-b8fe-9b189b617b8a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01VbPUmUQwsYeebBM4u6yK54","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014q4aHjpQUd8G3zFzaA8NSQ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","old_string":"- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID","new_string":"- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID\n- `idx_payments_method` on (`method`) - Filtering payments by method"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1201,"cache_read_input_tokens":151375,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1201},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VrDG9x3xTTLGrUsg7T","type":"assistant","uuid":"d91a3723-443c-46a1-a1b6-aaca7152355c","timestamp":"2026-02-11T15:13:11.198Z"}
{"parentUuid":"d91a3723-443c-46a1-a1b6-aaca7152355c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_014q4aHjpQUd8G3zFzaA8NSQ","toolUseID":"toolu_014q4aHjpQUd8G3zFzaA8NSQ","timestamp":"2026-02-11T15:13:11.207Z","uuid":"f151ec7c-60c3-463d-bd4b-b28632673b85"}
{"parentUuid":"d91a3723-443c-46a1-a1b6-aaca7152355c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014q4aHjpQUd8G3zFzaA8NSQ","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md has been updated successfully."}]},"uuid":"440d98aa-a681-4d05-b6ef-227bea9829fb","timestamp":"2026-02-11T15:13:11.206Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","oldString":"- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID","newString":"- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID\n- `idx_payments_method` on (`method`) - Filtering payments by method","originalFile":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","structuredPatch":[{"oldStart":835,"oldLines":7,"newStart":835,"newLines":8,"lines":[" **Indexes:**"," "," - `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order","-- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Webhook reconciliation by provider ID","+- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID","+- `idx_payments_method` on (`method`) - Filtering payments by method"," - `idx_payments_status` on (`status`) - Filtering payments by status"," "," **Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents)."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"d91a3723-443c-46a1-a1b6-aaca7152355c"}
{"parentUuid":"440d98aa-a681-4d05-b6ef-227bea9829fb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01VbPUmUQwsYeebBM4u6yK54","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DNUVi35Qep3kon9cwaiaQu","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","old_string":"**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).","new_string":"**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)."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1201,"cache_read_input_tokens":151375,"output_tokens":452,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1201,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2VrDG9x3xTTLGrUsg7T","type":"assistant","uuid":"ed9d6fb1-4e58-496d-9693-aece602b4a3f","timestamp":"2026-02-11T15:13:15.238Z"}
{"parentUuid":"ed9d6fb1-4e58-496d-9693-aece602b4a3f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01DNUVi35Qep3kon9cwaiaQu","toolUseID":"toolu_01DNUVi35Qep3kon9cwaiaQu","timestamp":"2026-02-11T15:13:15.247Z","uuid":"4686e91b-19bc-4d23-93f2-9cfffa13287b"}
{"parentUuid":"ed9d6fb1-4e58-496d-9693-aece602b4a3f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DNUVi35Qep3kon9cwaiaQu","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md has been updated successfully."}]},"uuid":"f62589e8-1940-40e9-8b4f-fa0c98a7796f","timestamp":"2026-02-11T15:13:15.246Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","oldString":"**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).","newString":"**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).","originalFile":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID\n- `idx_payments_method` on (`method`) - Filtering payments by method\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).\n\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","structuredPatch":[{"oldStart":839,"oldLines":7,"newStart":839,"newLines":7,"lines":[" - `idx_payments_method` on (`method`) - Filtering payments by method"," - `idx_payments_status` on (`status`) - Filtering payments by status"," ","-**Notes:** `raw_json_encrypted` stores the full payment provider response, encrypted at the application layer for PCI compliance. `amount` is in minor units (cents).","+**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)."," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"ed9d6fb1-4e58-496d-9693-aece602b4a3f"}
{"parentUuid":"f62589e8-1940-40e9-8b4f-fa0c98a7796f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_013dWcSJkbq1Hgzh3bp5TTUt","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the ER diagram to include the new columns:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":573,"cache_read_input_tokens":152576,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":573},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VrtkCbXGAzUA9CZcsH","type":"assistant","uuid":"1f4ceaa5-b544-4921-8c38-22bb6c60276a","timestamp":"2026-02-11T15:13:17.667Z"}
{"parentUuid":"1f4ceaa5-b544-4921-8c38-22bb6c60276a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_013dWcSJkbq1Hgzh3bp5TTUt","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LFrzmA2fckLcv8uJAts8px","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","old_string":"    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }","new_string":"    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT payment_method\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT method\n        TEXT status\n        INTEGER amount\n    }"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":573,"cache_read_input_tokens":152576,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":573},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VrtkCbXGAzUA9CZcsH","type":"assistant","uuid":"7da85a1c-8c3f-4702-8354-c7b4ad12729f","timestamp":"2026-02-11T15:13:20.410Z"}
{"parentUuid":"7da85a1c-8c3f-4702-8354-c7b4ad12729f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01LFrzmA2fckLcv8uJAts8px","toolUseID":"toolu_01LFrzmA2fckLcv8uJAts8px","timestamp":"2026-02-11T15:13:20.415Z","uuid":"c7db6f4b-5411-4ceb-8839-30adceb3a20e"}
{"parentUuid":"7da85a1c-8c3f-4702-8354-c7b4ad12729f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LFrzmA2fckLcv8uJAts8px","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md has been updated successfully."}]},"uuid":"6e5a4510-c2fb-4a93-8e41-e78552bc74dc","timestamp":"2026-02-11T15:13:20.414Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","oldString":"    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }","newString":"    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT payment_method\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT method\n        TEXT status\n        INTEGER amount\n    }","originalFile":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID\n- `idx_payments_method` on (`method`) - Filtering payments by method\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**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\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","structuredPatch":[{"oldStart":1403,"oldLines":6,"newStart":1403,"newLines":7,"lines":["         INTEGER store_id FK","         INTEGER customer_id FK","         TEXT order_number","+        TEXT payment_method","         TEXT status","         TEXT financial_status","         TEXT fulfillment_status"]},{"oldStart":1412,"oldLines":6,"newStart":1413,"newLines":7,"lines":["         INTEGER id PK","         INTEGER order_id FK","         TEXT provider","+        TEXT method","         TEXT status","         INTEGER amount","     }"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"7da85a1c-8c3f-4702-8354-c7b4ad12729f"}
{"parentUuid":"6e5a4510-c2fb-4a93-8e41-e78552bc74dc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GLmX954t3mzTLWMsLQkAro","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0143AvW3GEucyMf399YGzdFJ","name":"TaskUpdate","input":{"taskId":"1","status":"completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":374,"cache_read_input_tokens":153149,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":374},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VsH6NJZar7ZfQCBbhd","type":"assistant","uuid":"0d1f9b38-afa2-4f27-a898-69f8b5810e75","timestamp":"2026-02-11T15:13:23.087Z"}
{"parentUuid":"0d1f9b38-afa2-4f27-a898-69f8b5810e75","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0143AvW3GEucyMf399YGzdFJ","type":"tool_result","content":"Updated task #1 status"}]},"uuid":"d94dee74-ce60-48f7-8441-49faf769a070","timestamp":"2026-02-11T15:13:23.089Z","toolUseResult":{"success":true,"taskId":"1","updatedFields":["status"],"statusChange":{"from":"in_progress","to":"completed"}},"sourceToolAssistantUUID":"0d1f9b38-afa2-4f27-a898-69f8b5810e75"}
{"parentUuid":"d94dee74-ce60-48f7-8441-49faf769a070","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GxjWXqp9ZtYtCm5rK4dc4P","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me update **05-BUSINESS-LOGIC.md** - the largest change. Replace Section 10 (Payment Processing) with MockPSP and update Section 11 (Order Management) with payment-method-specific OMS:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":88,"cache_read_input_tokens":153523,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":88},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VsUK3pgwpe87cS92VA","type":"assistant","uuid":"7b3ed92f-ff28-4116-b91c-8396a9d000d8","timestamp":"2026-02-11T15:13:27.293Z"}
{"parentUuid":"7b3ed92f-ff28-4116-b91c-8396a9d000d8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GxjWXqp9ZtYtCm5rK4dc4P","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012NVARLA5tRrBYcWdwTrDhz","name":"TaskCreate","input":{"subject":"Update 05-BUSINESS-LOGIC.md - Mock PSP and payment-method-specific OMS","description":"Replace Section 10 with MockPaymentProvider, update Section 11 with payment-method-specific OMS, add fulfillment guard, auto-fulfillment for digital, auto-cancel for bank transfer","activeForm":"Updating business logic spec"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":88,"cache_read_input_tokens":153523,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":88},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VsUK3pgwpe87cS92VA","type":"assistant","uuid":"271bb4a2-37a7-4610-8f39-e00c8a64cc13","timestamp":"2026-02-11T15:13:29.090Z"}
{"parentUuid":"271bb4a2-37a7-4610-8f39-e00c8a64cc13","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012NVARLA5tRrBYcWdwTrDhz","type":"tool_result","content":"Task #2 created successfully: Update 05-BUSINESS-LOGIC.md - Mock PSP and payment-method-specific OMS"}]},"uuid":"9428ecff-d7f1-4bf2-8b19-9ab37291c111","timestamp":"2026-02-11T15:13:29.094Z","toolUseResult":{"task":{"id":"2","subject":"Update 05-BUSINESS-LOGIC.md - Mock PSP and payment-method-specific OMS"}},"sourceToolAssistantUUID":"271bb4a2-37a7-4610-8f39-e00c8a64cc13"}
{"parentUuid":"9428ecff-d7f1-4bf2-8b19-9ab37291c111","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014V5wApQVC4VXCAoj7vQt61","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UCt678JqmGPzE7JrgyVJYR","name":"TaskUpdate","input":{"taskId":"2","status":"in_progress"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":253,"cache_read_input_tokens":153611,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":253},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vsv37eZhXJL2sdAEck","type":"assistant","uuid":"267ee924-ecc3-4e04-be6b-0c6013cf1d17","timestamp":"2026-02-11T15:13:31.793Z"}
{"parentUuid":"267ee924-ecc3-4e04-be6b-0c6013cf1d17","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UCt678JqmGPzE7JrgyVJYR","type":"tool_result","content":"Updated task #2 status"}]},"uuid":"c248427e-283b-4a48-b104-e48dc6324f33","timestamp":"2026-02-11T15:13:31.796Z","toolUseResult":{"success":true,"taskId":"2","updatedFields":["status"],"statusChange":{"from":"pending","to":"in_progress"}},"sourceToolAssistantUUID":"267ee924-ecc3-4e04-be6b-0c6013cf1d17"}
{"parentUuid":"c248427e-283b-4a48-b104-e48dc6324f33","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01L4Q6RVA9vVMZ8op8rRZ3px","type":"message","role":"assistant","content":[{"type":"text","text":"Replace Section 10 (Payment Processing):"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":90,"cache_read_input_tokens":153864,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":90},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Vt7kpS71S4eQhxPMAm","type":"assistant","uuid":"ab6b0c4d-6d1e-4d7b-ba64-eb483585d3a7","timestamp":"2026-02-11T15:13:34.062Z"}
{"type":"file-history-snapshot","messageId":"03f4d7f9-97f7-430d-b440-c53b63cdb8b4","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v1","version":1,"backupTime":"2026-02-11T15:14:23.948Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"ab6b0c4d-6d1e-4d7b-ba64-eb483585d3a7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01L4Q6RVA9vVMZ8op8rRZ3px","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HwRj5F2cbpR3pRYpXCnfyw","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"## 10. Payment Processing\n\n### 10.1 Payment Provider Architecture\n\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `createSession(checkout)` - returns a PaymentSession value object (redirect URL, client secret, etc.)\n- `handleWebhook(request)` - processes incoming webhook, returns a WebhookResult\n- `refund(payment, amount)` - initiates a refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates provider selection based on store settings and delegates.\n\n**Provider selection:** Configured per store in `store_settings.settings_json` key `payment_providers` (array of provider strings).\n\n### 10.2 Stripe Integration\n\n**Implementation:** `App\\Services\\Payment\\StripePaymentProvider`\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant Stripe\n\n    Customer->>Storefront: Click \"Pay with Stripe\"\n    Storefront->>Backend: POST /api/storefront/v1/payments/stripe/checkout-session\n    Backend->>Stripe: Create Checkout Session (line items, success/cancel URLs, metadata with checkout ID)\n    Stripe-->>Backend: Session URL\n    Backend-->>Storefront: Redirect URL\n    Storefront->>Customer: Redirect to Stripe hosted page\n    Customer->>Stripe: Complete payment\n    Stripe->>Customer: Redirect to success_url\n    Stripe->>Backend: Webhook: checkout.session.completed\n    Backend->>Backend: Verify signature, check idempotency\n    Backend->>Backend: Queue ProcessStripeWebhook job\n    Backend->>Backend: Complete checkout, create order (idempotent)\n```\n\n**Webhook endpoint:** `POST /api/webhooks/stripe`\n\n**Webhook handling requirements:**\n1. Verify `Stripe-Signature` header using the webhook secret (stored in config, sourced from environment variable `STRIPE_WEBHOOK_SECRET`).\n2. If signature is invalid: return HTTP 400 immediately. Do not process.\n3. Check idempotency: look up the Stripe event ID in a processed events cache or table. If already processed, return HTTP 200 immediately.\n4. Dispatch a queued job for processing to ensure resilience.\n\n**Handled Stripe events:**\n\n| Event | Action |\n|---|---|\n| `checkout.session.completed` | Complete the checkout, create order |\n| `payment_intent.succeeded` | Update payment status to captured (if not using Checkout Sessions) |\n| `payment_intent.payment_failed` | Mark payment as failed, release inventory, notify customer |\n| `charge.refunded` | Update order financial status, create refund record |\n\n### 10.3 PayPal Integration (Stub)\n\n**Implementation:** `App\\Services\\Payment\\PayPalPaymentProvider`\n\n**Flow:**\n1. Create order via PayPal Orders API (v2).\n2. Return the PayPal approval URL for customer redirect.\n3. On return from PayPal: capture the payment.\n4. Webhook for async confirmation.\n5. Signature verification via PayPal's verification endpoint.\n\n### 10.4 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `requires_action`, `authorized`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> requires_action : Payment initiated\n    requires_action --> authorized : Provider authorizes\n    requires_action --> failed : Provider rejects\n    authorized --> captured : Payment captured\n    captured --> refunded : Full refund processed\n\n    note right of requires_action : Awaiting customer action or provider confirmation\n    note right of authorized : For Stripe Checkout Sessions, authorized + captured happen together\n```\n\n### 10.5 Queued Webhook Processing\n\n**Jobs:**\n- `App\\Jobs\\ProcessStripeWebhook` - handles incoming Stripe webhook events\n- `App\\Jobs\\ProcessPayPalWebhook` - handles incoming PayPal webhook events\n\nUsing queued jobs ensures resilience: if processing fails, the job retries automatically per Laravel's retry configuration.","new_string":"## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":90,"cache_read_input_tokens":153864,"output_tokens":2718,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":90,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2Vt7kpS71S4eQhxPMAm","type":"assistant","uuid":"03f4d7f9-97f7-430d-b440-c53b63cdb8b4","timestamp":"2026-02-11T15:14:23.944Z"}
{"parentUuid":"03f4d7f9-97f7-430d-b440-c53b63cdb8b4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01HwRj5F2cbpR3pRYpXCnfyw","toolUseID":"toolu_01HwRj5F2cbpR3pRYpXCnfyw","timestamp":"2026-02-11T15:14:23.955Z","uuid":"cf944615-f8ef-4cec-ac57-14fa0a87c145"}
{"parentUuid":"03f4d7f9-97f7-430d-b440-c53b63cdb8b4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HwRj5F2cbpR3pRYpXCnfyw","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"deb6521e-4c99-4091-a710-73313c622421","timestamp":"2026-02-11T15:14:23.955Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"## 10. Payment Processing\n\n### 10.1 Payment Provider Architecture\n\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `createSession(checkout)` - returns a PaymentSession value object (redirect URL, client secret, etc.)\n- `handleWebhook(request)` - processes incoming webhook, returns a WebhookResult\n- `refund(payment, amount)` - initiates a refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates provider selection based on store settings and delegates.\n\n**Provider selection:** Configured per store in `store_settings.settings_json` key `payment_providers` (array of provider strings).\n\n### 10.2 Stripe Integration\n\n**Implementation:** `App\\Services\\Payment\\StripePaymentProvider`\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant Stripe\n\n    Customer->>Storefront: Click \"Pay with Stripe\"\n    Storefront->>Backend: POST /api/storefront/v1/payments/stripe/checkout-session\n    Backend->>Stripe: Create Checkout Session (line items, success/cancel URLs, metadata with checkout ID)\n    Stripe-->>Backend: Session URL\n    Backend-->>Storefront: Redirect URL\n    Storefront->>Customer: Redirect to Stripe hosted page\n    Customer->>Stripe: Complete payment\n    Stripe->>Customer: Redirect to success_url\n    Stripe->>Backend: Webhook: checkout.session.completed\n    Backend->>Backend: Verify signature, check idempotency\n    Backend->>Backend: Queue ProcessStripeWebhook job\n    Backend->>Backend: Complete checkout, create order (idempotent)\n```\n\n**Webhook endpoint:** `POST /api/webhooks/stripe`\n\n**Webhook handling requirements:**\n1. Verify `Stripe-Signature` header using the webhook secret (stored in config, sourced from environment variable `STRIPE_WEBHOOK_SECRET`).\n2. If signature is invalid: return HTTP 400 immediately. Do not process.\n3. Check idempotency: look up the Stripe event ID in a processed events cache or table. If already processed, return HTTP 200 immediately.\n4. Dispatch a queued job for processing to ensure resilience.\n\n**Handled Stripe events:**\n\n| Event | Action |\n|---|---|\n| `checkout.session.completed` | Complete the checkout, create order |\n| `payment_intent.succeeded` | Update payment status to captured (if not using Checkout Sessions) |\n| `payment_intent.payment_failed` | Mark payment as failed, release inventory, notify customer |\n| `charge.refunded` | Update order financial status, create refund record |\n\n### 10.3 PayPal Integration (Stub)\n\n**Implementation:** `App\\Services\\Payment\\PayPalPaymentProvider`\n\n**Flow:**\n1. Create order via PayPal Orders API (v2).\n2. Return the PayPal approval URL for customer redirect.\n3. On return from PayPal: capture the payment.\n4. Webhook for async confirmation.\n5. Signature verification via PayPal's verification endpoint.\n\n### 10.4 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `requires_action`, `authorized`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> requires_action : Payment initiated\n    requires_action --> authorized : Provider authorizes\n    requires_action --> failed : Provider rejects\n    authorized --> captured : Payment captured\n    captured --> refunded : Full refund processed\n\n    note right of requires_action : Awaiting customer action or provider confirmation\n    note right of authorized : For Stripe Checkout Sessions, authorized + captured happen together\n```\n\n### 10.5 Queued Webhook Processing\n\n**Jobs:**\n- `App\\Jobs\\ProcessStripeWebhook` - handles incoming Stripe webhook events\n- `App\\Jobs\\ProcessPayPalWebhook` - handles incoming PayPal webhook events\n\nUsing queued jobs ensures resilience: if processing fails, the job retries automatically per Laravel's retry configuration.","newString":"## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n\n**Actions:**\n1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n5. Return the payment session data (redirect URL, client secret, etc.).\n\n#### Transition: payment_pending -> completed\n\n**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n\n**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n\n**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. Generate next sequential order number for this store\n\n    3. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    4. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    5. CREATE payment record:\n       - Link to order, provider info, provider_payment_id\n       - status = captured, amount = order total\n       - Encrypt raw provider response for PCI compliance\n\n    6. FOR EACH cart line:\n       CALL InventoryService.commit(variant, quantity)\n\n    7. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    8. SET cart status = 'converted'\n\n    9. SET checkout status = 'completed'\n\n    10. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing\n\n### 10.1 Payment Provider Architecture\n\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `createSession(checkout)` - returns a PaymentSession value object (redirect URL, client secret, etc.)\n- `handleWebhook(request)` - processes incoming webhook, returns a WebhookResult\n- `refund(payment, amount)` - initiates a refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates provider selection based on store settings and delegates.\n\n**Provider selection:** Configured per store in `store_settings.settings_json` key `payment_providers` (array of provider strings).\n\n### 10.2 Stripe Integration\n\n**Implementation:** `App\\Services\\Payment\\StripePaymentProvider`\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant Stripe\n\n    Customer->>Storefront: Click \"Pay with Stripe\"\n    Storefront->>Backend: POST /api/storefront/v1/payments/stripe/checkout-session\n    Backend->>Stripe: Create Checkout Session (line items, success/cancel URLs, metadata with checkout ID)\n    Stripe-->>Backend: Session URL\n    Backend-->>Storefront: Redirect URL\n    Storefront->>Customer: Redirect to Stripe hosted page\n    Customer->>Stripe: Complete payment\n    Stripe->>Customer: Redirect to success_url\n    Stripe->>Backend: Webhook: checkout.session.completed\n    Backend->>Backend: Verify signature, check idempotency\n    Backend->>Backend: Queue ProcessStripeWebhook job\n    Backend->>Backend: Complete checkout, create order (idempotent)\n```\n\n**Webhook endpoint:** `POST /api/webhooks/stripe`\n\n**Webhook handling requirements:**\n1. Verify `Stripe-Signature` header using the webhook secret (stored in config, sourced from environment variable `STRIPE_WEBHOOK_SECRET`).\n2. If signature is invalid: return HTTP 400 immediately. Do not process.\n3. Check idempotency: look up the Stripe event ID in a processed events cache or table. If already processed, return HTTP 200 immediately.\n4. Dispatch a queued job for processing to ensure resilience.\n\n**Handled Stripe events:**\n\n| Event | Action |\n|---|---|\n| `checkout.session.completed` | Complete the checkout, create order |\n| `payment_intent.succeeded` | Update payment status to captured (if not using Checkout Sessions) |\n| `payment_intent.payment_failed` | Mark payment as failed, release inventory, notify customer |\n| `charge.refunded` | Update order financial status, create refund record |\n\n### 10.3 PayPal Integration (Stub)\n\n**Implementation:** `App\\Services\\Payment\\PayPalPaymentProvider`\n\n**Flow:**\n1. Create order via PayPal Orders API (v2).\n2. Return the PayPal approval URL for customer redirect.\n3. On return from PayPal: capture the payment.\n4. Webhook for async confirmation.\n5. Signature verification via PayPal's verification endpoint.\n\n### 10.4 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `requires_action`, `authorized`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> requires_action : Payment initiated\n    requires_action --> authorized : Provider authorizes\n    requires_action --> failed : Provider rejects\n    authorized --> captured : Payment captured\n    captured --> refunded : Full refund processed\n\n    note right of requires_action : Awaiting customer action or provider confirmation\n    note right of authorized : For Stripe Checkout Sessions, authorized + captured happen together\n```\n\n### 10.5 Queued Webhook Processing\n\n**Jobs:**\n- `App\\Jobs\\ProcessStripeWebhook` - handles incoming Stripe webhook events\n- `App\\Jobs\\ProcessPayPalWebhook` - handles incoming PayPal webhook events\n\nUsing queued jobs ensures resilience: if processing fails, the job retries automatically per Laravel's retry configuration.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The raw payment provider response must be encrypted before storage (PCI compliance).\n- The entire operation must be atomic (single database transaction).\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n**Creating a fulfillment:**\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    2. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    3. CREATE fulfillment_lines for each order line and quantity\n\n    4. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    5. DISPATCH FulfillmentCreated event\n```\n\n**Fulfillment status transitions:**\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created\n    pending --> shipped : Merchant marks as shipped\n    shipped --> delivered : Merchant marks as delivered\n\n    note right of shipped : Sets shipped_at timestamp, sends tracking email\n```\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":902,"oldLines":97,"newStart":902,"newLines":132,"lines":[" "," ---"," ","-## 10. Payment Processing","+## 10. Payment Processing (Mock PSP)"," ","-### 10.1 Payment Provider Architecture","+### 10.1 Mock Payment Provider Architecture"," ","+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.","+"," **Interface:** `App\\Contracts\\PaymentProvider`"," "," Must implement:","-- `createSession(checkout)` - returns a PaymentSession value object (redirect URL, client secret, etc.)","-- `handleWebhook(request)` - processes incoming webhook, returns a WebhookResult","-- `refund(payment, amount)` - initiates a refund, returns a RefundResult","+- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)","+- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult"," ","-**Service:** `App\\Services\\PaymentService` - orchestrates provider selection based on store settings and delegates.","+**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider."," ","-**Provider selection:** Configured per store in `store_settings.settings_json` key `payment_providers` (array of provider strings).","+**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`"," ","-### 10.2 Stripe Integration","+### 10.2 Supported Payment Methods"," ","-**Implementation:** `App\\Services\\Payment\\StripePaymentProvider`","+**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)"," ","+| Method | Behavior | Capture Timing |","+|---|---|---|","+| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |","+| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |","+| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |","+","+### 10.3 Magic Card Numbers (Credit Card Testing)","+","+When payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:","+","+| Card Number | Outcome | PaymentResult |","+|---|---|---|","+| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |","+| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |","+| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |","+| Any other valid-looking number | Success | `success = true`, status = `captured` |","+","+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.","+","+### 10.4 Mock Payment Flow","+"," ```mermaid"," sequenceDiagram","     participant Customer","     participant Storefront","     participant Backend","-    participant Stripe","+    participant MockPSP"," ","-    Customer->>Storefront: Click \"Pay with Stripe\"","-    Storefront->>Backend: POST /api/storefront/v1/payments/stripe/checkout-session","-    Backend->>Stripe: Create Checkout Session (line items, success/cancel URLs, metadata with checkout ID)","-    Stripe-->>Backend: Session URL","-    Backend-->>Storefront: Redirect URL","-    Storefront->>Customer: Redirect to Stripe hosted page","-    Customer->>Stripe: Complete payment","-    Stripe->>Customer: Redirect to success_url","-    Stripe->>Backend: Webhook: checkout.session.completed","-    Backend->>Backend: Verify signature, check idempotency","-    Backend->>Backend: Queue ProcessStripeWebhook job","-    Backend->>Backend: Complete checkout, create order (idempotent)","+    Customer->>Storefront: Select payment method + enter details","+    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)","+    Backend->>Backend: Reserve inventory (if not already reserved)","+    Backend->>MockPSP: charge(checkout, paymentMethodData)","+","+    alt Credit Card / PayPal (instant capture)","+        MockPSP-->>Backend: PaymentResult (success/failure)","+        alt Success","+            Backend->>Backend: Create order (financial_status = paid)","+            Backend->>Backend: Commit inventory, mark cart converted","+            Backend-->>Storefront: 200 OK (order confirmation)","+        else Failure (decline / insufficient funds)","+            Backend->>Backend: Release reserved inventory","+            Backend-->>Storefront: 422 (error code + message)","+        end","+    else Bank Transfer (deferred)","+        MockPSP-->>Backend: PaymentResult (success = true, status = pending)","+        Backend->>Backend: Create order (financial_status = pending)","+        Backend->>Backend: Inventory remains reserved (not committed)","+        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)","+    end"," ```"," ","-**Webhook endpoint:** `POST /api/webhooks/stripe`","+### 10.5 Payment State Machine"," ","-**Webhook handling requirements:**","-1. Verify `Stripe-Signature` header using the webhook secret (stored in config, sourced from environment variable `STRIPE_WEBHOOK_SECRET`).","-2. If signature is invalid: return HTTP 400 immediately. Do not process.","-3. Check idempotency: look up the Stripe event ID in a processed events cache or table. If already processed, return HTTP 200 immediately.","-4. Dispatch a queued job for processing to ensure resilience.","+**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`"," ","-**Handled Stripe events:**","+```mermaid","+stateDiagram-v2","+    [*] --> captured : Credit card / PayPal (success)","+    [*] --> failed : Credit card (decline)","+    [*] --> pending : Bank transfer (awaiting payment)","+    pending --> captured : Admin confirms payment received","+    pending --> failed : Auto-cancel job (unpaid timeout)","+    captured --> refunded : Full refund processed","+```"," ","-| Event | Action |","-|---|---|","-| `checkout.session.completed` | Complete the checkout, create order |","-| `payment_intent.succeeded` | Update payment status to captured (if not using Checkout Sessions) |","-| `payment_intent.payment_failed` | Mark payment as failed, release inventory, notify customer |","-| `charge.refunded` | Update order financial status, create refund record |","+### 10.6 Bank Transfer Instructions"," ","-### 10.3 PayPal Integration (Stub)","+When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:"," ","-**Implementation:** `App\\Services\\Payment\\PayPalPaymentProvider`","+| Field | Value |","+|---|---|","+| Bank name | Mock Bank AG |","+| IBAN | DE89 3704 0044 0532 0130 00 |","+| BIC | COBADEFFXXX |","+| Reference | Order number (e.g., #1001) |","+| Amount | Order total formatted in EUR |"," ","-**Flow:**","-1. Create order via PayPal Orders API (v2).","-2. Return the PayPal approval URL for customer redirect.","-3. On return from PayPal: capture the payment.","-4. Webhook for async confirmation.","-5. Signature verification via PayPal's verification endpoint.","+These are static mock values. No real bank interaction occurs."," ","-### 10.4 Payment State Machine","+### 10.7 Admin: Confirm Payment (Bank Transfer)"," ","-**Enum:** `App\\Enums\\PaymentStatus` - values: `requires_action`, `authorized`, `captured`, `failed`, `refunded`","+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`)."," ","-```mermaid","-stateDiagram-v2","-    [*] --> requires_action : Payment initiated","-    requires_action --> authorized : Provider authorizes","-    requires_action --> failed : Provider rejects","-    authorized --> captured : Payment captured","-    captured --> refunded : Full refund processed","+**Action flow:**","+1. Admin clicks \"Confirm Payment\" button.","+2. Backend updates `payments.status` from `pending` to `captured`.","+3. Backend updates `orders.financial_status` from `pending` to `paid`.","+4. Backend updates `orders.status` from `pending` to `paid`.","+5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).","+6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).","+7. Dispatch `OrderPaid` event."," ","-    note right of requires_action : Awaiting customer action or provider confirmation","-    note right of authorized : For Stripe Checkout Sessions, authorized + captured happen together","-```","+### 10.8 Auto-Cancel Unpaid Bank Transfer Orders"," ","-### 10.5 Queued Webhook Processing","+**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`"," ","-**Jobs:**","-- `App\\Jobs\\ProcessStripeWebhook` - handles incoming Stripe webhook events","-- `App\\Jobs\\ProcessPayPalWebhook` - handles incoming PayPal webhook events","+- Schedule: daily","+- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.","+- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7."," ","-Using queued jobs ensures resilience: if processing fails, the job retries automatically per Laravel's retry configuration.","+**Cancel action:**","+1. Release reserved inventory via `InventoryService.release()`.","+2. Update `orders.financial_status` to `voided`.","+3. Update `orders.status` to `cancelled`.","+4. Update `payments.status` to `failed`.","+5. Dispatch `OrderCancelled` event."," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"03f4d7f9-97f7-430d-b440-c53b63cdb8b4"}
{"parentUuid":"deb6521e-4c99-4091-a710-73313c622421","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BuFNyZ4QW4x4Y1NJbyKEGX","type":"message","role":"assistant","content":[{"type":"text","text":"Now update Section 11 (Order Management) with payment-method-specific OMS:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2762,"cache_read_input_tokens":153954,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2762},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VwxYHjjW9vvv3bLuvd","type":"assistant","uuid":"6620cd44-0ffa-4fa3-ba24-e14b163f2645","timestamp":"2026-02-11T15:14:26.493Z"}
{"parentUuid":"6620cd44-0ffa-4fa3-ba24-e14b163f2645","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BuFNyZ4QW4x4Y1NJbyKEGX","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KsTcBnTEYvY5onzw4MLJ4p","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The raw payment provider response must be encrypted before storage (PCI compliance).\n- The entire operation must be atomic (single database transaction).","new_string":"### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2762,"cache_read_input_tokens":153954,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2762},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VwxYHjjW9vvv3bLuvd","type":"assistant","uuid":"db5c2a94-d549-49d5-82e0-29762d2a10ad","timestamp":"2026-02-11T15:14:36.550Z"}
{"parentUuid":"db5c2a94-d549-49d5-82e0-29762d2a10ad","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01KsTcBnTEYvY5onzw4MLJ4p","toolUseID":"toolu_01KsTcBnTEYvY5onzw4MLJ4p","timestamp":"2026-02-11T15:14:36.555Z","uuid":"4df0d340-aed0-495d-88b1-f1d806626749"}
{"parentUuid":"db5c2a94-d549-49d5-82e0-29762d2a10ad","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KsTcBnTEYvY5onzw4MLJ4p","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"fa3ee23c-c47e-4fc6-b0b2-7f4c8fbc5379","timestamp":"2026-02-11T15:14:36.555Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The raw payment provider response must be encrypted before storage (PCI compliance).\n- The entire operation must be atomic (single database transaction).","newString":"### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n\n**Actions:**\n1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n5. Return the payment session data (redirect URL, client secret, etc.).\n\n#### Transition: payment_pending -> completed\n\n**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n\n**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n\n**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. Generate next sequential order number for this store\n\n    3. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    4. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    5. CREATE payment record:\n       - Link to order, provider info, provider_payment_id\n       - status = captured, amount = order total\n       - Encrypt raw provider response for PCI compliance\n\n    6. FOR EACH cart line:\n       CALL InventoryService.commit(variant, quantity)\n\n    7. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    8. SET cart status = 'converted'\n\n    9. SET checkout status = 'completed'\n\n    10. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The raw payment provider response must be encrypted before storage (PCI compliance).\n- The entire operation must be atomic (single database transaction).\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n**Creating a fulfillment:**\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    2. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    3. CREATE fulfillment_lines for each order line and quantity\n\n    4. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    5. DISPATCH FulfillmentCreated event\n```\n\n**Fulfillment status transitions:**\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created\n    pending --> shipped : Merchant marks as shipped\n    shipped --> delivered : Merchant marks as delivered\n\n    note right of shipped : Sets shipped_at timestamp, sends tracking email\n```\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1040,"oldLines":9,"newStart":1040,"newLines":18,"lines":[" **Key requirements for the order record:**"," - Snapshot all checkout data (addresses, email, totals) at time of order creation."," - Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.","-- The raw payment provider response must be encrypted before storage (PCI compliance).","+- The mock payment response is encrypted before storage."," - The entire operation must be atomic (single database transaction).","+- The `payment_method` from the checkout is copied to the order record."," ","+**Payment-method-specific order creation:**","+","+| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |","+|---|---|---|---|---|","+| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |","+| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |","+| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |","+"," ### 11.2 Order Numbering"," "," - Sequential per store, starting at 1001."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"db5c2a94-d549-49d5-82e0-29762d2a10ad"}
{"parentUuid":"fa3ee23c-c47e-4fc6-b0b2-7f4c8fbc5379","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RviR9HkCPahmQqb1FHQEka","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the fulfillment section to add the fulfillment guard, shipped/delivered admin buttons, and auto-fulfillment:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":633,"cache_read_input_tokens":156716,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":633},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VxubUCE6baXv6sPomz","type":"assistant","uuid":"abdbc8a1-c0e5-4c84-897c-3989fcde843d","timestamp":"2026-02-11T15:14:39.629Z"}
{"parentUuid":"abdbc8a1-c0e5-4c84-897c-3989fcde843d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RviR9HkCPahmQqb1FHQEka","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CLSFnUw8G3ZZhURH9PJS6J","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n**Creating a fulfillment:**\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    2. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    3. CREATE fulfillment_lines for each order line and quantity\n\n    4. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    5. DISPATCH FulfillmentCreated event\n```\n\n**Fulfillment status transitions:**\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created\n    pending --> shipped : Merchant marks as shipped\n    shipped --> delivered : Merchant marks as delivered\n\n    note right of shipped : Sets shipped_at timestamp, sends tracking email\n```\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.","new_string":"### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":633,"cache_read_input_tokens":156716,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":633},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2VxubUCE6baXv6sPomz","type":"assistant","uuid":"d425fad1-e773-45b9-bdf3-ed677528ab0b","timestamp":"2026-02-11T15:15:10.004Z"}
{"parentUuid":"d425fad1-e773-45b9-bdf3-ed677528ab0b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01CLSFnUw8G3ZZhURH9PJS6J","toolUseID":"toolu_01CLSFnUw8G3ZZhURH9PJS6J","timestamp":"2026-02-11T15:15:10.009Z","uuid":"4ec6b07e-3b43-409e-a454-634b16afd263"}
{"parentUuid":"d425fad1-e773-45b9-bdf3-ed677528ab0b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CLSFnUw8G3ZZhURH9PJS6J","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"5079a053-b3cc-4384-94a7-d97c242fc5e9","timestamp":"2026-02-11T15:15:10.009Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n**Creating a fulfillment:**\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    2. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    3. CREATE fulfillment_lines for each order line and quantity\n\n    4. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    5. DISPATCH FulfillmentCreated event\n```\n\n**Fulfillment status transitions:**\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created\n    pending --> shipped : Merchant marks as shipped\n    shipped --> delivered : Merchant marks as delivered\n\n    note right of shipped : Sets shipped_at timestamp, sends tracking email\n```\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.","newString":"### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n\n**Actions:**\n1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n5. Return the payment session data (redirect URL, client secret, etc.).\n\n#### Transition: payment_pending -> completed\n\n**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n\n**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n\n**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. Generate next sequential order number for this store\n\n    3. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    4. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    5. CREATE payment record:\n       - Link to order, provider info, provider_payment_id\n       - status = captured, amount = order total\n       - Encrypt raw provider response for PCI compliance\n\n    6. FOR EACH cart line:\n       CALL InventoryService.commit(variant, quantity)\n\n    7. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    8. SET cart status = 'converted'\n\n    9. SET checkout status = 'completed'\n\n    10. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n**Creating a fulfillment:**\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    2. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    3. CREATE fulfillment_lines for each order line and quantity\n\n    4. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    5. DISPATCH FulfillmentCreated event\n```\n\n**Fulfillment status transitions:**\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created\n    pending --> shipped : Merchant marks as shipped\n    shipped --> delivered : Merchant marks as delivered\n\n    note right of shipped : Sets shipped_at timestamp, sends tracking email\n```\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1144,"oldLines":8,"newStart":1144,"newLines":23,"lines":[" "," **Service:** `App\\Services\\FulfillmentService`"," ","-**Creating a fulfillment:**","+#### Fulfillment Guard"," ","+**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.","+","+| `financial_status` | Fulfillment Allowed? | Reason |","+|---|---|---|","+| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |","+| `authorized` | **No** | Payment authorized but not captured |","+| `paid` | **Yes** | Payment confirmed |","+| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |","+| `refunded` | **No** | Fully refunded, nothing to ship |","+| `voided` | **No** | Payment voided / cancelled |","+","+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.","+","+#### Creating a Fulfillment","+"," Input:"," - `lines` - map of order_line_id to quantity, specifying which items are in this shipment"," - `trackingData` - tracking_company, tracking_number, tracking_url"]},{"oldStart":1154,"oldLines":16,"newStart":1169,"newLines":19,"lines":[" "," ```"," PROCEDURE createFulfillment(order, lines, trackingData):","-    1. FOR EACH line in request:","+    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')","+       IF NOT: throw FulfillmentGuardException","+","+    2. FOR EACH line in request:","        fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)","        unfulfilled = order_line.quantity - fulfilledSoFar","        VALIDATE requested quantity <= unfulfilled"," ","-    2. CREATE Fulfillment record with status = 'pending' and tracking data","+    3. CREATE Fulfillment record with status = 'pending' and tracking data"," ","-    3. CREATE fulfillment_lines for each order line and quantity","+    4. CREATE fulfillment_lines for each order line and quantity"," ","-    4. Determine new fulfillment status:","+    5. Determine new fulfillment status:","        FOR EACH order_line in order:","            totalFulfilled = SUM(all fulfillment_lines for this order_line)","            IF totalFulfilled < order_line.quantity:"]},{"oldStart":1175,"oldLines":25,"newStart":1193,"newLines":65,"lines":["        ELSE:","            SET order.fulfillment_status = 'partial'"," ","-    5. DISPATCH FulfillmentCreated event","+    6. DISPATCH FulfillmentCreated event"," ```"," ","-**Fulfillment status transitions:**","+#### Fulfillment Status Transitions (Admin Button Actions)"," ","+Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.","+"," ```mermaid"," stateDiagram-v2","-    [*] --> pending : Fulfillment created","-    pending --> shipped : Merchant marks as shipped","-    shipped --> delivered : Merchant marks as delivered","+    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")","+    pending --> shipped : Admin clicks \"Mark as shipped\"","+    shipped --> delivered : Admin clicks \"Mark as delivered\""," ","-    note right of shipped : Sets shipped_at timestamp, sends tracking email","+    note right of pending : Fulfillment record created with tracking data","+    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer","+    note right of delivered : Sets delivered_at timestamp"," ```"," ","+**Admin buttons on each fulfillment card:**","+","+| Button | Shown When | Action |","+|---|---|---|","+| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |","+| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |","+"," When transitioning to `shipped`:"," - Set `shipped_at` timestamp."," - Dispatch `FulfillmentShipped` event."," - Send email notification to customer with tracking information."," ","+When transitioning to `delivered`:","+- Dispatch `FulfillmentDelivered` event.","+","+### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method","+","+The fulfillment guard interacts with inventory differently depending on the payment method:","+","+| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |","+|---|---|---|---|","+| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |","+| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |","+","+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.","+","+### 11.7 Auto-Fulfillment for Digital Products","+","+When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):","+","+1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).","+2. If yes: automatically create a fulfillment record covering all order lines:","+   - `status = 'delivered'` (skip pending and shipped)","+   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`","+   - `shipped_at = now`","+   - Set `order.fulfillment_status = 'fulfilled'`","+   - Set `order.status = 'fulfilled'`","+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).","+","+This ensures digital product orders are immediately marked as complete after payment.","+"," ### 11.6 Order Export"," "," `GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"d425fad1-e773-45b9-bdf3-ed677528ab0b"}
{"parentUuid":"5079a053-b3cc-4384-94a7-d97c242fc5e9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01C4FGrAcKV8GT3TjnKxQSCS","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the checkout state machine transitions in Section 6.2 to reflect the new payment flow:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2001,"cache_read_input_tokens":157349,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2001},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W1Mj9LMgfRG93CF41Z","type":"assistant","uuid":"34d850ce-b4f8-4b49-9f18-c3004ff09f4a","timestamp":"2026-02-11T15:15:12.856Z"}
{"parentUuid":"34d850ce-b4f8-4b49-9f18-c3004ff09f4a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01C4FGrAcKV8GT3TjnKxQSCS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VSJVdNffZiTq7vF8P8F9N1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"#### Transition: shipping_selected -> payment_pending\n\n**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n\n**Actions:**\n1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n5. Return the payment session data (redirect URL, client secret, etc.).\n\n#### Transition: payment_pending -> completed\n\n**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n\n**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n\n**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. Generate next sequential order number for this store\n\n    3. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    4. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    5. CREATE payment record:\n       - Link to order, provider info, provider_payment_id\n       - status = captured, amount = order total\n       - Encrypt raw provider response for PCI compliance\n\n    6. FOR EACH cart line:\n       CALL InventoryService.commit(variant, quantity)\n\n    7. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    8. SET cart status = 'converted'\n\n    9. SET checkout status = 'completed'\n\n    10. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```","new_string":"#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2001,"cache_read_input_tokens":157349,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2001},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W1Mj9LMgfRG93CF41Z","type":"assistant","uuid":"f26fddd8-6390-4d14-b981-176e24874fd2","timestamp":"2026-02-11T15:15:36.794Z"}
{"parentUuid":"f26fddd8-6390-4d14-b981-176e24874fd2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01VSJVdNffZiTq7vF8P8F9N1","toolUseID":"toolu_01VSJVdNffZiTq7vF8P8F9N1","timestamp":"2026-02-11T15:15:36.800Z","uuid":"2d140fab-bc17-4313-8234-31e2ee728e32"}
{"parentUuid":"f26fddd8-6390-4d14-b981-176e24874fd2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VSJVdNffZiTq7vF8P8F9N1","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"2ddeaead-f80c-45b2-8a15-37eaf3d1b7da","timestamp":"2026-02-11T15:15:36.799Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"#### Transition: shipping_selected -> payment_pending\n\n**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n\n**Actions:**\n1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n5. Return the payment session data (redirect URL, client secret, etc.).\n\n#### Transition: payment_pending -> completed\n\n**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n\n**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n\n**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. Generate next sequential order number for this store\n\n    3. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    4. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    5. CREATE payment record:\n       - Link to order, provider info, provider_payment_id\n       - status = captured, amount = order total\n       - Encrypt raw provider response for PCI compliance\n\n    6. FOR EACH cart line:\n       CALL InventoryService.commit(variant, quantity)\n\n    7. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    8. SET cart status = 'converted'\n\n    9. SET checkout status = 'completed'\n\n    10. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```","newString":"#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `beginPayment(checkout)` - returns a PaymentSession value object\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n\n**Actions:**\n1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n2. Create a payment intent or checkout session with the configured payment provider (see Section 10).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n5. Return the payment session data (redirect URL, client secret, etc.).\n\n#### Transition: payment_pending -> completed\n\n**Triggered by:** Successful payment webhook callback or redirect-back confirmation.\n\n**Method:** `completeCheckout(checkout, paymentData)` - returns an Order\n\n**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. Generate next sequential order number for this store\n\n    3. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    4. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    5. CREATE payment record:\n       - Link to order, provider info, provider_payment_id\n       - status = captured, amount = order total\n       - Encrypt raw provider response for PCI compliance\n\n    6. FOR EACH cart line:\n       CALL InventoryService.commit(variant, quantity)\n\n    7. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    8. SET cart status = 'converted'\n\n    9. SET checkout status = 'completed'\n\n    10. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":486,"oldLines":67,"newStart":486,"newLines":91,"lines":[" "," #### Transition: shipping_selected -> payment_pending"," ","-**Method:** `beginPayment(checkout)` - returns a PaymentSession value object","+**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method"," "," **Requires:**"," - All previous data is valid (address, shipping method)."," - Totals have been calculated and stored.","+- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`."," "," **Actions:**","-1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).","-2. Create a payment intent or checkout session with the configured payment provider (see Section 10).","+1. Store `payment_method` on the checkout record.","+2. Reserve inventory for all cart lines (call InventoryService.reserve for each line)."," 3. Set `expires_at` on the checkout to current time + 24 hours."," 4. Transition status to `payment_pending`.","-5. Return the payment session data (redirect URL, client secret, etc.)."," "," #### Transition: payment_pending -> completed"," ","-**Triggered by:** Successful payment webhook callback or redirect-back confirmation.","+**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order"," ","-**Method:** `completeCheckout(checkout, paymentData)` - returns an Order","+**Triggered by:** Customer submitting the payment form (onsite, no external redirect)."," ","-**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.","+**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."," "," **Actions (all within a single database transaction):**"," "," ```","-PROCEDURE completeCheckout(checkout, paymentData):","+PROCEDURE completeCheckout(checkout, paymentMethodData):","     BEGIN TRANSACTION"," ","     1. IF order already exists for this checkout:","            RETURN existing order (idempotent)"," ","-    2. Generate next sequential order number for this store","+    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)","+       IF result.success = false:","+           Release reserved inventory","+           THROW PaymentFailedException(result.errorCode)"," ","-    3. CREATE order record with:","+    3. Generate next sequential order number for this store","+","+    4. DETERMINE order statuses based on payment method:","+       IF checkout.payment_method IN ('credit_card', 'paypal'):","+           orderStatus = 'paid'","+           financialStatus = 'paid'","+           paymentStatus = 'captured'","+           inventoryAction = 'commit'","+       ELSE IF checkout.payment_method = 'bank_transfer':","+           orderStatus = 'pending'","+           financialStatus = 'pending'","+           paymentStatus = 'pending'","+           inventoryAction = 'keep_reserved'","+","+    5. CREATE order record with:","        - All checkout data (addresses, email, totals snapshot)","-       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'","+       - payment_method = checkout.payment_method","+       - status = orderStatus, financial_status = financialStatus","+       - fulfillment_status = 'unfulfilled'","        - placed_at = now"," ","-    4. FOR EACH cart line:","+    6. FOR EACH cart line:","        CREATE order_line with snapshot data:","        - title_snapshot = product title + variant option labels","        - sku_snapshot = variant SKU at time of purchase","        - unit_price_amount, quantity, total_amount","        - tax_lines_json, discount_allocations_json"," ","-    5. CREATE payment record:","-       - Link to order, provider info, provider_payment_id","-       - status = captured, amount = order total","-       - Encrypt raw provider response for PCI compliance","+    7. CREATE payment record:","+       - Link to order, provider = 'mock', method = checkout.payment_method","+       - provider_payment_id = mock reference ID","+       - status = paymentStatus, amount = order total"," ","-    6. FOR EACH cart line:","-       CALL InventoryService.commit(variant, quantity)","+    8. IF inventoryAction = 'commit':","+       FOR EACH cart line:","+           CALL InventoryService.commit(variant, quantity)","+       // Note: for bank_transfer, inventory stays reserved until admin confirms payment"," ","-    7. IF checkout has a discount code:","+    9. IF checkout has a discount code:","        INCREMENT discount's usage_count by 1"," ","-    8. SET cart status = 'converted'","+    10. SET cart status = 'converted'"," ","-    9. SET checkout status = 'completed'","+    11. SET checkout status = 'completed'"," ","-    10. DISPATCH OrderCreated event","+    12. IF inventoryAction = 'commit' AND all order lines are digital:","+        Auto-create fulfillment (see Section 11.7)"," ","+    13. DISPATCH OrderCreated event","+","     COMMIT TRANSACTION","     RETURN order"," ```"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"f26fddd8-6390-4d14-b981-176e24874fd2"}
{"parentUuid":"2ddeaead-f80c-45b2-8a15-37eaf3d1b7da","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01UMsqqPYpCFB1WkPN7J8xkB","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the Enum and Value Object registries to reflect the changes:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1681,"cache_read_input_tokens":159350,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1681},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W3L21dCVoP49NSuckb","type":"assistant","uuid":"b4dde943-7428-4d14-8203-a3af759f3b10","timestamp":"2026-02-11T15:15:39.458Z"}
{"parentUuid":"b4dde943-7428-4d14-8203-a3af759f3b10","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01UMsqqPYpCFB1WkPN7J8xkB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Me7xTuVafGv1rnu288wGYd","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |","new_string":"| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1681,"cache_read_input_tokens":159350,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1681},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W3L21dCVoP49NSuckb","type":"assistant","uuid":"526908a9-99fa-441a-bf05-b6e17c8ff038","timestamp":"2026-02-11T15:15:42.324Z"}
{"parentUuid":"526908a9-99fa-441a-bf05-b6e17c8ff038","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Me7xTuVafGv1rnu288wGYd","toolUseID":"toolu_01Me7xTuVafGv1rnu288wGYd","timestamp":"2026-02-11T15:15:42.332Z","uuid":"b0759f28-2f53-4e3e-ae3c-72e13fcf628d"}
{"parentUuid":"526908a9-99fa-441a-bf05-b6e17c8ff038","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Me7xTuVafGv1rnu288wGYd","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"99d1f032-1b3f-4b52-9830-a9408ee2a96e","timestamp":"2026-02-11T15:15:42.331Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |","newString":"| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1678,"oldLines":8,"newStart":1678,"newLines":8,"lines":[" | DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |"," | CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |"," | CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |","-| PaymentStatus | RequiresAction (`requires_action`), Authorized (`authorized`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |","-| PaymentProviderType | Stripe (`stripe`), Paypal (`paypal`) |","+| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |","+| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |"," | OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |"," | FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |"," | FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"526908a9-99fa-441a-bf05-b6e17c8ff038"}
{"parentUuid":"99d1f032-1b3f-4b52-9830-a9408ee2a96e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GT8s1t4pHUKd1MTrHZBRtZ","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the Event registry and Scheduled Jobs registry:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":468,"cache_read_input_tokens":161031,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":468},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W3jsi33FuJwTX5UqZ8","type":"assistant","uuid":"02cf753d-32c4-42e5-8c3d-bcc8b9319733","timestamp":"2026-02-11T15:15:45.167Z"}
{"parentUuid":"02cf753d-32c4-42e5-8c3d-bcc8b9319733","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GT8s1t4pHUKd1MTrHZBRtZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012Z99Sj58R4QcXN93aSivg8","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |","new_string":"| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":468,"cache_read_input_tokens":161031,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":468},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W3jsi33FuJwTX5UqZ8","type":"assistant","uuid":"5faab06d-1720-468d-9e61-d3233a77e1b7","timestamp":"2026-02-11T15:15:48.930Z"}
{"parentUuid":"5faab06d-1720-468d-9e61-d3233a77e1b7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_012Z99Sj58R4QcXN93aSivg8","toolUseID":"toolu_012Z99Sj58R4QcXN93aSivg8","timestamp":"2026-02-11T15:15:48.935Z","uuid":"552bcc9f-f7c8-4136-8381-ae94bfc6ff34"}
{"parentUuid":"5faab06d-1720-468d-9e61-d3233a77e1b7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012Z99Sj58R4QcXN93aSivg8","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"6b76d211-5eb2-4819-82ef-5cac3636f40c","timestamp":"2026-02-11T15:15:48.935Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |","newString":"| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1601,"oldLines":8,"newStart":1601,"newLines":11,"lines":[" | `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |"," | `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |"," | `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |","+| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |"," | `FulfillmentCreated` | Update order fulfillment status |"," | `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |","+| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |","+| `OrderCancelled` | Release reserved inventory, send cancellation email |"," "," All events should be placed in `App\\Events`. All listeners in `App\\Listeners`."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"5faab06d-1720-468d-9e61-d3233a77e1b7"}
{"parentUuid":"6b76d211-5eb2-4819-82ef-5cac3636f40c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GrohVgV7ZGJXHvTuR78sby","type":"message","role":"assistant","content":[{"type":"text","text":"Update the scheduled jobs registry:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":352,"cache_read_input_tokens":161499,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":352},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W4E5brv1BHJwtco7fQ","type":"assistant","uuid":"6eaf52e2-eac7-4473-92e7-bea81cc73b4d","timestamp":"2026-02-11T15:15:51.894Z"}
{"parentUuid":"6eaf52e2-eac7-4473-92e7-bea81cc73b4d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GrohVgV7ZGJXHvTuR78sby","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012U5tz4MKB9eSbS8QapE3Ms","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |","new_string":"| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":352,"cache_read_input_tokens":161499,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":352},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W4E5brv1BHJwtco7fQ","type":"assistant","uuid":"01629087-1ea8-4bc5-b84a-23e36af23d03","timestamp":"2026-02-11T15:15:59.107Z"}
{"parentUuid":"01629087-1ea8-4bc5-b84a-23e36af23d03","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_012U5tz4MKB9eSbS8QapE3Ms","toolUseID":"toolu_012U5tz4MKB9eSbS8QapE3Ms","timestamp":"2026-02-11T15:15:59.112Z","uuid":"f5f86498-8c80-47f6-8d97-81d01ed85792"}
{"parentUuid":"01629087-1ea8-4bc5-b84a-23e36af23d03","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012U5tz4MKB9eSbS8QapE3Ms","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"f5222c06-8646-42c7-a153-6e084c75545f","timestamp":"2026-02-11T15:15:59.112Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |","newString":"| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |\n| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1618,"oldLines":6,"newStart":1618,"newLines":7,"lines":[" | `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |"," | `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |"," | `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |","+| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |"," "," **Queued jobs (dispatched on demand, not scheduled):**"," "]},{"oldStart":1625,"oldLines":8,"newStart":1626,"newLines":6,"lines":[" |---|---|"," | `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |"," | `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |","-| `App\\Jobs\\ProcessStripeWebhook` | Handle incoming Stripe webhook event |","-| `App\\Jobs\\ProcessPayPalWebhook` | Handle incoming PayPal webhook event |"," "," Schedule registration belongs in `routes/console.php`."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"01629087-1ea8-4bc5-b84a-23e36af23d03"}
{"parentUuid":"f5222c06-8646-42c7-a153-6e084c75545f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G35DYWLBP819RWSygnBVPJ","type":"message","role":"assistant","content":[{"type":"text","text":"Update the Service Class Registry:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":625,"cache_read_input_tokens":161851,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":625},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W4yazbrULo3iwYqg6i","type":"assistant","uuid":"eddb0d62-a93e-48a4-99a6-e5009a3aa2a8","timestamp":"2026-02-11T15:16:03.019Z"}
{"parentUuid":"eddb0d62-a93e-48a4-99a6-e5009a3aa2a8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G35DYWLBP819RWSygnBVPJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Lx7JJKm7dDtPvshyy16Sit","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |","new_string":"| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":625,"cache_read_input_tokens":161851,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":625},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W4yazbrULo3iwYqg6i","type":"assistant","uuid":"162d7bba-e5ec-495f-870c-d86872f4d0a3","timestamp":"2026-02-11T15:16:06.356Z"}
{"parentUuid":"162d7bba-e5ec-495f-870c-d86872f4d0a3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Lx7JJKm7dDtPvshyy16Sit","toolUseID":"toolu_01Lx7JJKm7dDtPvshyy16Sit","timestamp":"2026-02-11T15:16:06.362Z","uuid":"be6fae31-f9c4-447c-81a0-4ab021c2306b"}
{"parentUuid":"162d7bba-e5ec-495f-870c-d86872f4d0a3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Lx7JJKm7dDtPvshyy16Sit","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"4ce8d157-d92e-47c6-8f0a-236974ddbafb","timestamp":"2026-02-11T15:16:06.362Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |","newString":"| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment provider orchestration |\n| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |\n| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1646,"oldLines":9,"newStart":1646,"newLines":8,"lines":[" | ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |"," | StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |"," | ShippingCalculator | `App\\Services` | Zone matching, rate calculation |","-| PaymentService | `App\\Services` | Payment provider orchestration |","-| StripePaymentProvider | `App\\Services\\Payment` | Stripe Checkout Sessions and webhook handling |","-| PayPalPaymentProvider | `App\\Services\\Payment` | PayPal Orders API integration (stub) |","+| PaymentService | `App\\Services` | Payment processing orchestration |","+| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |"," | RefundService | `App\\Services` | Refund processing and inventory restock |"," | FulfillmentService | `App\\Services` | Fulfillment creation and status management |"," | CustomerService | `App\\Services` | Customer registration, account management |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"162d7bba-e5ec-495f-870c-d86872f4d0a3"}
{"parentUuid":"4ce8d157-d92e-47c6-8f0a-236974ddbafb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Ev1awfAFYkMc1kWeVu7wi8","type":"message","role":"assistant","content":[{"type":"text","text":"Update the Contracts registry:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":303,"cache_read_input_tokens":162476,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":303},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W5WizcjD1fuitWToNv","type":"assistant","uuid":"0cfd59ba-fd28-4e59-9ba8-866438a0e929","timestamp":"2026-02-11T15:16:08.658Z"}
{"parentUuid":"0cfd59ba-fd28-4e59-9ba8-866438a0e929","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Ev1awfAFYkMc1kWeVu7wi8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014ayzuT61rQTw3mAeGKMHrX","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |","new_string":"| PaymentProvider | `App\\Contracts` | MockPaymentProvider |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":303,"cache_read_input_tokens":162476,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":303},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W5WizcjD1fuitWToNv","type":"assistant","uuid":"0e46b214-c53e-403f-a8ef-daae03affebd","timestamp":"2026-02-11T15:16:10.063Z"}
{"parentUuid":"0e46b214-c53e-403f-a8ef-daae03affebd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_014ayzuT61rQTw3mAeGKMHrX","toolUseID":"toolu_014ayzuT61rQTw3mAeGKMHrX","timestamp":"2026-02-11T15:16:10.071Z","uuid":"fc1863b7-85e2-43e4-a254-29dbdf0d566d"}
{"parentUuid":"0e46b214-c53e-403f-a8ef-daae03affebd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014ayzuT61rQTw3mAeGKMHrX","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"04cd6768-3ef3-4b55-bbb2-0a30dd6c79de","timestamp":"2026-02-11T15:16:10.070Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |","newString":"| PaymentProvider | `App\\Contracts` | MockPaymentProvider |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1662,"oldLines":7,"newStart":1662,"newLines":7,"lines":[" | Interface | Namespace | Implementing Classes |"," |---|---|---|"," | TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |","-| PaymentProvider | `App\\Contracts` | StripePaymentProvider, PayPalPaymentProvider |","+| PaymentProvider | `App\\Contracts` | MockPaymentProvider |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"0e46b214-c53e-403f-a8ef-daae03affebd"}
{"parentUuid":"04cd6768-3ef3-4b55-bbb2-0a30dd6c79de","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KJqT9QLLnqFP52RnTbmtF6","type":"message","role":"assistant","content":[{"type":"text","text":"Update the Value Object registry to replace PaymentSession:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":227,"cache_read_input_tokens":162779,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":227},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W5nV2s8ow2vqNuXsfm","type":"assistant","uuid":"821aa621-d1f5-4b56-9d2a-b81bed9a2333","timestamp":"2026-02-11T15:16:13.026Z"}
{"parentUuid":"821aa621-d1f5-4b56-9d2a-b81bed9a2333","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KJqT9QLLnqFP52RnTbmtF6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NQUKgCP8cVQtPVgPVdV7q2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |","new_string":"| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":227,"cache_read_input_tokens":162779,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":227},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W5nV2s8ow2vqNuXsfm","type":"assistant","uuid":"afc81e13-c5f4-4a53-b828-ec450f2712f0","timestamp":"2026-02-11T15:16:15.053Z"}
{"parentUuid":"afc81e13-c5f4-4a53-b828-ec450f2712f0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01NQUKgCP8cVQtPVgPVdV7q2","toolUseID":"toolu_01NQUKgCP8cVQtPVgPVdV7q2","timestamp":"2026-02-11T15:16:15.058Z","uuid":"598ddbb2-cd90-4520-a1da-2267209e1db1"}
{"parentUuid":"afc81e13-c5f4-4a53-b828-ec450f2712f0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NQUKgCP8cVQtPVgPVdV7q2","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"f8f887ac-d9ce-4287-bbe9-bdfc35b95448","timestamp":"2026-02-11T15:16:15.057Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |","newString":"| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1702,"oldLines":7,"newStart":1702,"newLines":7,"lines":[" | TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |"," | TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |"," | DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |","-| PaymentSession | redirectUrl (string), sessionId (string), clientSecret (nullable string) | Payment provider session data |","+| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |"," | WebhookResult | success (bool), message (string) | Webhook processing result |"," | RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |"," | Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"afc81e13-c5f4-4a53-b828-ec450f2712f0"}
{"parentUuid":"f8f887ac-d9ce-4287-bbe9-bdfc35b95448","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012ZrvX117GpFsYAFEf8FhAr","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012jGb627yyk4wN9Dwydaqm1","name":"TaskUpdate","input":{"taskId":"2","status":"completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":409,"cache_read_input_tokens":163006,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":409},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W69fVgg7tsEtmBMFth","type":"assistant","uuid":"49be5bd2-11c0-4348-968e-77eb1fcb34c7","timestamp":"2026-02-11T15:16:17.922Z"}
{"parentUuid":"49be5bd2-11c0-4348-968e-77eb1fcb34c7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012jGb627yyk4wN9Dwydaqm1","type":"tool_result","content":"Updated task #2 status"}]},"uuid":"815173c1-e11b-470c-b1a5-b9f1219fbeb5","timestamp":"2026-02-11T15:16:17.925Z","toolUseResult":{"success":true,"taskId":"2","updatedFields":["status"],"statusChange":{"from":"in_progress","to":"completed"}},"sourceToolAssistantUUID":"49be5bd2-11c0-4348-968e-77eb1fcb34c7"}
{"parentUuid":"815173c1-e11b-470c-b1a5-b9f1219fbeb5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Ya1oZEhvVTxFoaQCWgiPKz","type":"message","role":"assistant","content":[{"type":"text","text":"Now update **02-API-ROUTES.md** - replace payment endpoints with mock payment flow:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":88,"cache_read_input_tokens":163415,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":88},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W6MvguA5pV6LDvxzar","type":"assistant","uuid":"1d8d0e31-f2fd-47c4-95f2-40493b341dfd","timestamp":"2026-02-11T15:16:21.217Z"}
{"parentUuid":"1d8d0e31-f2fd-47c4-95f2-40493b341dfd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Ya1oZEhvVTxFoaQCWgiPKz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013sxZyCrc4KZabPgZWAwzH2","name":"TaskCreate","input":{"subject":"Update 02-API-ROUTES.md - Replace payment endpoints with mock flow","description":"Replace Stripe/PayPal payment endpoints with single POST /checkouts/{id}/pay, update sequence diagrams","activeForm":"Updating API routes spec"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":88,"cache_read_input_tokens":163415,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":88},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W6MvguA5pV6LDvxzar","type":"assistant","uuid":"5c95ca23-4768-402f-a072-116047dc8a0f","timestamp":"2026-02-11T15:16:22.960Z"}
{"parentUuid":"5c95ca23-4768-402f-a072-116047dc8a0f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013sxZyCrc4KZabPgZWAwzH2","type":"tool_result","content":"Task #3 created successfully: Update 02-API-ROUTES.md - Replace payment endpoints with mock flow"}]},"uuid":"dee50fe2-7d94-4978-a92f-7f9f4a31868a","timestamp":"2026-02-11T15:16:22.963Z","toolUseResult":{"task":{"id":"3","subject":"Update 02-API-ROUTES.md - Replace payment endpoints with mock flow"}},"sourceToolAssistantUUID":"5c95ca23-4768-402f-a072-116047dc8a0f"}
{"parentUuid":"dee50fe2-7d94-4978-a92f-7f9f4a31868a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01R2T8jJC5m8vNSgFNKmKMTq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FCipB6cYUtUwLgM5ge7v7T","name":"TaskUpdate","input":{"taskId":"3","status":"in_progress"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":188,"cache_read_input_tokens":163503,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":188},"output_tokens":19,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W6jdQYWUKp2AaAxnQA","type":"assistant","uuid":"03dcfd0f-51e8-4348-9b22-3bd58f3851ce","timestamp":"2026-02-11T15:16:25.635Z"}
{"parentUuid":"03dcfd0f-51e8-4348-9b22-3bd58f3851ce","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FCipB6cYUtUwLgM5ge7v7T","type":"tool_result","content":"Updated task #3 status"}]},"uuid":"c92be0be-23d9-4666-a587-e298a56196af","timestamp":"2026-02-11T15:16:25.636Z","toolUseResult":{"success":true,"taskId":"3","updatedFields":["status"],"statusChange":{"from":"pending","to":"in_progress"}},"sourceToolAssistantUUID":"03dcfd0f-51e8-4348-9b22-3bd58f3851ce"}
{"parentUuid":"c92be0be-23d9-4666-a587-e298a56196af","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01S9WAPLfV1pFPoodcWGLojB","type":"message","role":"assistant","content":[{"type":"text","text":"Replace the checkout flow sequence diagram:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":90,"cache_read_input_tokens":163691,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":90},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W6w4VYErWkAGxWKaNu","type":"assistant","uuid":"40286c7a-784b-44bd-8973-41d13bcd14b6","timestamp":"2026-02-11T15:16:29.635Z"}
{"type":"file-history-snapshot","messageId":"c1e6d549-4751-4d1f-b68e-0569a69f014f","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v1","version":1,"backupTime":"2026-02-11T15:14:23.948Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v1","version":1,"backupTime":"2026-02-11T15:16:45.432Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"40286c7a-784b-44bd-8973-41d13bcd14b6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01S9WAPLfV1pFPoodcWGLojB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JcE53vgvaSM8yZgV1eecNy","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe API\n    participant WH as Webhook Handler\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    alt Stripe Checkout Session\n        C->>API: POST /payments/stripe/checkout-session\n        API->>Stripe: Create Checkout Session\n        Stripe-->>API: session_url\n        API-->>C: 201 (session_url)\n        C->>Stripe: Redirect to hosted payment page\n        Stripe-->>C: Redirect to success_url\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else Stripe Elements (embedded)\n        C->>API: POST /payments/stripe/payment-intent\n        API->>Stripe: Create PaymentIntent\n        Stripe-->>API: client_secret\n        API-->>C: 201 (client_secret)\n        C->>Stripe: Confirm payment (client-side)\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else PayPal\n        C->>API: POST /payments/paypal/create-order\n        API-->>C: 201 (approval_url)\n        C->>C: Redirect to PayPal approval\n        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n        WH-->>C: 200 OK\n    end\n\n    C->>API: GET /checkouts/{id} (poll or redirect)\n    API-->>C: 200 Checkout (status: completed)\n```","new_string":"#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":90,"cache_read_input_tokens":163691,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":90},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W6w4VYErWkAGxWKaNu","type":"assistant","uuid":"c1e6d549-4751-4d1f-b68e-0569a69f014f","timestamp":"2026-02-11T15:16:45.429Z"}
{"parentUuid":"c1e6d549-4751-4d1f-b68e-0569a69f014f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01JcE53vgvaSM8yZgV1eecNy","toolUseID":"toolu_01JcE53vgvaSM8yZgV1eecNy","timestamp":"2026-02-11T15:16:45.435Z","uuid":"095337e1-4e1c-4cae-b881-bef0cb8f4707"}
{"parentUuid":"c1e6d549-4751-4d1f-b68e-0569a69f014f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JcE53vgvaSM8yZgV1eecNy","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"a51b5de6-5ae7-4603-9237-940c5688e4cc","timestamp":"2026-02-11T15:16:45.435Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe API\n    participant WH as Webhook Handler\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    alt Stripe Checkout Session\n        C->>API: POST /payments/stripe/checkout-session\n        API->>Stripe: Create Checkout Session\n        Stripe-->>API: session_url\n        API-->>C: 201 (session_url)\n        C->>Stripe: Redirect to hosted payment page\n        Stripe-->>C: Redirect to success_url\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else Stripe Elements (embedded)\n        C->>API: POST /payments/stripe/payment-intent\n        API->>Stripe: Create PaymentIntent\n        Stripe-->>API: client_secret\n        API-->>C: 201 (client_secret)\n        C->>Stripe: Confirm payment (client-side)\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else PayPal\n        C->>API: POST /payments/paypal/create-order\n        API-->>C: 201 (approval_url)\n        C->>C: Redirect to PayPal approval\n        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n        WH-->>C: 200 OK\n    end\n\n    C->>API: GET /checkouts/{id} (poll or redirect)\n    API-->>C: 200 Checkout (status: completed)\n```","newString":"#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe API\n    participant WH as Webhook Handler\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    alt Stripe Checkout Session\n        C->>API: POST /payments/stripe/checkout-session\n        API->>Stripe: Create Checkout Session\n        Stripe-->>API: session_url\n        API-->>C: 201 (session_url)\n        C->>Stripe: Redirect to hosted payment page\n        Stripe-->>C: Redirect to success_url\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else Stripe Elements (embedded)\n        C->>API: POST /payments/stripe/payment-intent\n        API->>Stripe: Create PaymentIntent\n        Stripe-->>API: client_secret\n        API-->>C: 201 (client_secret)\n        C->>Stripe: Confirm payment (client-side)\n        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n        WH-->>Stripe: 200 OK\n    else PayPal\n        C->>API: POST /payments/paypal/create-order\n        API-->>C: 201 (approval_url)\n        C->>C: Redirect to PayPal approval\n        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED\n        WH-->>C: 200 OK\n    end\n\n    C->>API: GET /checkouts/{id} (poll or redirect)\n    API-->>C: 200 Checkout (status: completed)\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoints\n\n#### Payment Flow Sequence Diagram (Stripe)\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe\n    participant WH as Webhook Receiver\n    participant Order as Order System\n\n    C->>API: POST /payments/stripe/payment-intent\n    API->>Stripe: Create PaymentIntent (amount, currency, metadata)\n    Stripe-->>API: PaymentIntent (client_secret)\n    API-->>C: 201 {client_secret, payment_intent_id, amount, currency}\n\n    C->>Stripe: confirmCardPayment(client_secret)\n    Stripe-->>C: Payment result\n\n    Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n    WH->>WH: Verify Stripe-Signature header\n    WH->>WH: Check idempotency (event.id)\n    WH->>Order: Create order, decrement inventory, send confirmation\n    WH-->>Stripe: 200 {received: true}\n```\n\n#### POST /api/storefront/v1/payments/stripe/checkout-session\n\nCreate a Stripe-hosted Checkout Session for the checkout. Redirects the user to Stripe's hosted payment page.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7,\n  \"success_url\": \"https://acme.test/checkout/7/confirmation\",\n  \"cancel_url\": \"https://acme.test/checkout/7\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n| `success_url` | string | Yes | Valid URL. |\n| `cancel_url` | string | Yes | Valid URL. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"session_id\": \"cs_live_abc123def456\",\n  \"session_url\": \"https://checkout.stripe.com/c/pay/cs_live_abc123def456\",\n  \"expires_at\": \"2026-02-11T10:30:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `422` - Validation errors\n- `502` - Stripe API error\n\n---\n\n#### POST /api/storefront/v1/payments/stripe/payment-intent\n\nCreate a Stripe PaymentIntent for embedded payment form (Stripe Elements).\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"client_secret\": \"pi_abc123_secret_def456\",\n  \"payment_intent_id\": \"pi_abc123\",\n  \"amount\": 6545,\n  \"currency\": \"eur\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `502` - Stripe API error\n\n---\n\n#### POST /api/storefront/v1/payments/paypal/create-order\n\nCreate a PayPal order for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7,\n  \"return_url\": \"https://acme.test/checkout/7/confirmation\",\n  \"cancel_url\": \"https://acme.test/checkout/7\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n| `return_url` | string | Yes | Valid URL. |\n| `cancel_url` | string | Yes | Valid URL. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"paypal_order_id\": \"5O190127TN364715T\",\n  \"approval_url\": \"https://www.paypal.com/checkoutnow?token=5O190127TN364715T\",\n  \"status\": \"CREATED\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `422` - Validation errors\n- `502` - PayPal API error\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"stripe\",\n        \"provider_payment_id\": \"pi_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\nThese endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n\n**Prefix:** `/api/webhooks`\n**Middleware:** None (raw request body needed for signature verification)\n\n---\n\n### POST /api/webhooks/stripe\n\nReceive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n\n- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"evt_abc123\",\n  \"type\": \"payment_intent.succeeded\",\n  \"data\": {\n    \"object\": {\n      \"id\": \"pi_abc123\",\n      \"amount\": 6545,\n      \"currency\": \"eur\",\n      \"status\": \"succeeded\",\n      \"metadata\": {\n        \"checkout_id\": 7,\n        \"store_id\": 1\n      }\n    }\n  },\n  \"created\": 1707645000\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n2. Track idempotency via `event.id` to prevent duplicate processing\n3. Dispatch appropriate action based on event type\n\n**Supported Stripe event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `payment_intent.succeeded` | Transition order to `paid`, decrement inventory |\n| `payment_intent.payment_failed` | Mark payment as failed, notify customer |\n| `charge.refunded` | Process refund, update order financial_status |\n| `checkout.session.completed` | Reconcile Checkout Session flow |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Invalid signature or malformed payload\n\n---\n\n### POST /api/webhooks/paypal\n\nReceive PayPal webhook notifications.\n\n- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"WH-abc123\",\n  \"event_type\": \"PAYMENT.CAPTURE.COMPLETED\",\n  \"resource\": {\n    \"id\": \"5O190127TN364715T\",\n    \"status\": \"COMPLETED\",\n    \"amount\": {\n      \"currency_code\": \"EUR\",\n      \"value\": \"65.45\"\n    },\n    \"custom_id\": 7\n  },\n  \"create_time\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify signature by calling PayPal's verification endpoint\n2. Check `verification_status` is `SUCCESS`; reject if `FAILURE`\n3. Track idempotency via `event.id`\n4. Dispatch action based on `event_type`\n\n**Supported PayPal event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `PAYMENT.CAPTURE.COMPLETED` | Transition order to `paid`, decrement inventory |\n| `PAYMENT.CAPTURE.DENIED` | Mark payment as failed |\n| `PAYMENT.CAPTURE.REFUNDED` | Process refund |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Verification failed or malformed payload\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n#### Webhooks\n\nApplied to `/api/webhooks/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:webhooks` | Rate limiting (100/min per IP) |\n\nNo session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":420,"oldLines":8,"newStart":420,"newLines":7,"lines":[" sequenceDiagram","     participant C as Customer Browser","     participant API as Storefront API","-    participant Stripe as Stripe API","-    participant WH as Webhook Handler","+    participant MockPSP as Mock Payment Provider"," ","     C->>API: POST /checkouts (cart_id, email)","     API-->>C: 201 Checkout (status: started)"]},{"oldStart":437,"oldLines":33,"newStart":436,"newLines":23,"lines":["         API-->>C: 200 Checkout (totals recalculated)","     end"," ","-    alt Stripe Checkout Session","-        C->>API: POST /payments/stripe/checkout-session","-        API->>Stripe: Create Checkout Session","-        Stripe-->>API: session_url","-        API-->>C: 201 (session_url)","-        C->>Stripe: Redirect to hosted payment page","-        Stripe-->>C: Redirect to success_url","-        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)","-        WH-->>Stripe: 200 OK","-    else Stripe Elements (embedded)","-        C->>API: POST /payments/stripe/payment-intent","-        API->>Stripe: Create PaymentIntent","-        Stripe-->>API: client_secret","-        API-->>C: 201 (client_secret)","-        C->>Stripe: Confirm payment (client-side)","-        Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)","-        WH-->>Stripe: 200 OK","-    else PayPal","-        C->>API: POST /payments/paypal/create-order","-        API-->>C: 201 (approval_url)","-        C->>C: Redirect to PayPal approval","-        Note over WH: PayPal sends PAYMENT.CAPTURE.COMPLETED","-        WH-->>C: 200 OK","-    end","+    C->>API: POST /checkouts/{id}/pay (payment_method + details)","+    API->>API: Reserve inventory","+    API->>MockPSP: charge(checkout, paymentMethodData)"," ","-    C->>API: GET /checkouts/{id} (poll or redirect)","-    API-->>C: 200 Checkout (status: completed)","+    alt Credit Card / PayPal (instant capture)","+        MockPSP-->>API: PaymentResult (success)","+        API->>API: Create order (paid), commit inventory","+        API-->>C: 200 (order confirmation data)","+    else Credit Card (decline)","+        MockPSP-->>API: PaymentResult (failed)","+        API->>API: Release reserved inventory","+        API-->>C: 422 (error: card_declined / insufficient_funds)","+    else Bank Transfer (deferred)","+        MockPSP-->>API: PaymentResult (pending)","+        API->>API: Create order (pending), inventory stays reserved","+        API-->>C: 200 (order confirmation + bank transfer instructions)","+    end"," ```"," "," #### POST /api/storefront/v1/checkouts"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"c1e6d549-4751-4d1f-b68e-0569a69f014f"}
{"parentUuid":"a51b5de6-5ae7-4603-9237-940c5688e4cc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012QaM9UspcUF48F4dSTxFHu","type":"message","role":"assistant","content":[{"type":"text","text":"Now replace the payment endpoints section (2.3):"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1189,"cache_read_input_tokens":163781,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1189},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W8Pch86QFZVg91y24P","type":"assistant","uuid":"3295ac11-c992-4e00-8f95-2d81eb48533b","timestamp":"2026-02-11T15:16:48.709Z"}
{"parentUuid":"3295ac11-c992-4e00-8f95-2d81eb48533b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012QaM9UspcUF48F4dSTxFHu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01S7pdTBn1EjEJVv5FEwaa6p","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"### 2.3 Payment Endpoints\n\n#### Payment Flow Sequence Diagram (Stripe)\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant Stripe as Stripe\n    participant WH as Webhook Receiver\n    participant Order as Order System\n\n    C->>API: POST /payments/stripe/payment-intent\n    API->>Stripe: Create PaymentIntent (amount, currency, metadata)\n    Stripe-->>API: PaymentIntent (client_secret)\n    API-->>C: 201 {client_secret, payment_intent_id, amount, currency}\n\n    C->>Stripe: confirmCardPayment(client_secret)\n    Stripe-->>C: Payment result\n\n    Stripe->>WH: POST /webhooks/stripe (payment_intent.succeeded)\n    WH->>WH: Verify Stripe-Signature header\n    WH->>WH: Check idempotency (event.id)\n    WH->>Order: Create order, decrement inventory, send confirmation\n    WH-->>Stripe: 200 {received: true}\n```\n\n#### POST /api/storefront/v1/payments/stripe/checkout-session\n\nCreate a Stripe-hosted Checkout Session for the checkout. Redirects the user to Stripe's hosted payment page.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7,\n  \"success_url\": \"https://acme.test/checkout/7/confirmation\",\n  \"cancel_url\": \"https://acme.test/checkout/7\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n| `success_url` | string | Yes | Valid URL. |\n| `cancel_url` | string | Yes | Valid URL. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"session_id\": \"cs_live_abc123def456\",\n  \"session_url\": \"https://checkout.stripe.com/c/pay/cs_live_abc123def456\",\n  \"expires_at\": \"2026-02-11T10:30:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `422` - Validation errors\n- `502` - Stripe API error\n\n---\n\n#### POST /api/storefront/v1/payments/stripe/payment-intent\n\nCreate a Stripe PaymentIntent for embedded payment form (Stripe Elements).\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"client_secret\": \"pi_abc123_secret_def456\",\n  \"payment_intent_id\": \"pi_abc123\",\n  \"amount\": 6545,\n  \"currency\": \"eur\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `502` - Stripe API error\n\n---\n\n#### POST /api/storefront/v1/payments/paypal/create-order\n\nCreate a PayPal order for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"checkout_id\": 7,\n  \"return_url\": \"https://acme.test/checkout/7/confirmation\",\n  \"cancel_url\": \"https://acme.test/checkout/7\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `checkout_id` | integer | Yes | Must reference a checkout in `shipping_selected` or `payment_pending` status. |\n| `return_url` | string | Yes | Valid URL. |\n| `cancel_url` | string | Yes | Valid URL. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"paypal_order_id\": \"5O190127TN364715T\",\n  \"approval_url\": \"https://www.paypal.com/checkoutnow?token=5O190127TN364715T\",\n  \"status\": \"CREATED\"\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment\n- `422` - Validation errors\n- `502` - PayPal API error","new_string":"### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1189,"cache_read_input_tokens":163781,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1189},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2W8Pch86QFZVg91y24P","type":"assistant","uuid":"ab25d8a3-fedd-4eda-984b-691d48547fbd","timestamp":"2026-02-11T15:17:18.378Z"}
{"parentUuid":"f26146ec-f745-4681-9987-65716b1de80b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01S7pdTBn1EjEJVv5FEwaa6p","toolUseID":"toolu_01S7pdTBn1EjEJVv5FEwaa6p","timestamp":"2026-02-11T15:17:18.387Z","uuid":"d7035ce6-1a62-4bf2-970c-a41feac3964d"}
{"parentUuid":null,"logicalParentUuid":"f26146ec-f745-4681-9987-65716b1de80b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"compact_boundary","content":"Conversation compacted","isMeta":false,"timestamp":"2026-02-11T15:18:56.966Z","uuid":"da169bf5-4386-4619-95fa-f7fd4f398cd4","level":"info","compactMetadata":{"trigger":"auto","preTokens":167266}}
{"parentUuid":"da169bf5-4386-4619-95fa-f7fd4f398cd4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nAnalysis:\nLet me chronologically analyze the conversation:\n\n1. **Context from previous conversation (summarized)**: The user has been working on a Shopify-clone e-commerce platform. Through several rounds of discussion, they:\n   - Asked for spec summaries, user stories, seed data details\n   - Requested onsite checkout with payment selection using a mock PSP (no real Stripe/PayPal)\n   - Agreed on 3 payment methods: Credit Card, PayPal, Bank Transfer\n   - Magic card numbers for testing\n   - Auto-capture for Card/PayPal, deferred for Bank Transfer\n   - Requested OMS workflow differentiation per payment method and shipment method\n   - Explicitly asked: \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\"\n   - Clarified: \"Events (like 'shipped') are triggered by admin buttons\"\n\n2. **Current conversation**: Continues from the previous context. The assistant was tasked with updating all 9 spec files. The conversation picks up with:\n   - Reading all spec files to understand full scope\n   - Creating tasks for tracking\n   - Making edits to spec files\n\n3. **Files read**:\n   - `specs/01-DATABASE-SCHEMA.md` (full, 1678 lines)\n   - `specs/05-BUSINESS-LOGIC.md` (full, 1583 lines)\n   - `specs/02-API-ROUTES.md` (lines 400-999)\n   - `specs/03-ADMIN-UI.md` (lines 900-1150)\n   - `specs/04-STOREFRONT-UI.md` (lines 900-1149)\n   - `specs/07-SEEDERS-AND-TEST-DATA.md` (lines 1-200, 300-400, 1200-1900)\n   - `specs/08-PLAYWRIGHT-E2E-PLAN.md` (lines 1-800)\n   - `specs/09-IMPLEMENTATION-ROADMAP.md` (lines 1-800)\n\n4. **Edits completed so far**:\n\n   **01-DATABASE-SCHEMA.md** (Task #1 - COMPLETED):\n   - Added `payment_method` column to `checkouts` table (TEXT, nullable, CHECK IN credit_card/paypal/bank_transfer)\n   - Added `payment_method` column to `orders` table (TEXT, NOT NULL, CHECK IN credit_card/paypal/bank_transfer)\n   - Changed `payments.provider` from CHECK IN ('stripe', 'paypal') to CHECK IN ('mock'), default 'mock'\n   - Added `payments.method` column (TEXT, NOT NULL, CHECK IN credit_card/paypal/bank_transfer)\n   - Updated payments index to include method\n   - Updated payments notes about mock PSP\n   - Updated ER diagram to include new columns\n\n   **05-BUSINESS-LOGIC.md** (Task #2 - COMPLETED):\n   - Replaced Section 10 (Payment Processing) entirely: removed Stripe/PayPal integration, added MockPaymentProvider with 3 payment methods, magic card numbers, mock payment flow diagram\n   - Added Section 10.6 (Bank Transfer Instructions)\n   - Added Section 10.7 (Admin: Confirm Payment for bank transfer)\n   - Added Section 10.8 (Auto-Cancel Unpaid Bank Transfer Orders job)\n   - Updated Section 11.1 (Order Creation) with payment-method-specific table\n   - Replaced Section 11.5 (Fulfillment) with fulfillment guard, admin button actions (Mark as shipped, Mark as delivered)\n   - Added Section 11.6 (Inventory Behavior per Payment Method)\n   - Added Section 11.7 (Auto-Fulfillment for Digital Products)\n   - Updated Section 6.2 checkout transitions: replaced beginPayment with selectPaymentMethod, updated completeCheckout procedure for payment-method-specific logic\n   - Updated Enum registry: replaced PaymentStatus/PaymentProviderType with PaymentMethod/PaymentStatus\n   - Updated Event registry: added OrderPaid, FulfillmentDelivered, OrderCancelled events\n   - Updated Scheduled Jobs: added CancelUnpaidBankTransferOrders, removed ProcessStripeWebhook/ProcessPayPalWebhook\n   - Updated Service registry: replaced Stripe/PayPal providers with MockPaymentProvider\n   - Updated Contracts: PaymentProvider now only implemented by MockPaymentProvider\n   - Updated Value Objects: replaced PaymentSession with PaymentResult\n\n   **02-API-ROUTES.md** (Task #3 - IN PROGRESS, partially done):\n   - Replaced checkout flow sequence diagram (removed Stripe/PayPal/webhook, added MockPSP flow)\n   - Replaced Section 2.3 entirely: removed Stripe checkout-session, payment-intent, and PayPal create-order endpoints; added single `POST /checkouts/{id}/pay` endpoint with credit_card/paypal/bank_transfer request bodies, success/error responses, magic card number error codes\n\n5. **Remaining files to edit** (not yet started):\n   - `specs/03-ADMIN-UI.md` - Add \"Confirm Payment\" button, fulfillment guard messaging, \"Mark as shipped\"/\"Mark as delivered\" buttons\n   - `specs/04-STOREFRONT-UI.md` - Replace checkout Step 4 with payment method selection + onsite forms\n   - `specs/07-SEEDERS-AND-TEST-DATA.md` - Add payment_method to all orders, add bank transfer order, update PaymentFactory\n   - `specs/08-PLAYWRIGHT-E2E-PLAN.md` - Update checkout tests, add payment method tests\n   - `specs/09-IMPLEMENTATION-ROADMAP.md` - Update phases for mock PSP\n\n6. **Task tracking state**:\n   - Task #1: COMPLETED (01-DATABASE-SCHEMA.md)\n   - Task #2: COMPLETED (05-BUSINESS-LOGIC.md)\n   - Task #3: IN PROGRESS (02-API-ROUTES.md - partially done, the main edit was completed)\n\n7. **No errors encountered** during editing.\n\n8. **User messages in this conversation**:\n   - The only user message is the continuation prompt asking to continue the previous conversation's work.\n   - Then the summarization request.\n\nSummary:\n1. Primary Request and Intent:\n   The user is building a Shopify-clone e-commerce platform with Laravel 12, Livewire v4, Flux UI Free, Tailwind CSS v4, and SQLite. Through multiple rounds of discussion (mostly in a previous conversation that was compacted), the user made these key decisions and requests:\n   \n   - **Onsite checkout with mock PSP**: Replace Stripe/PayPal integration with a Mock Payment Service Provider. No external API calls, no webhooks, no redirects. All payment processing happens in-process.\n   - **Three payment methods**: Credit Card, PayPal, Bank Transfer - each with different OMS workflows.\n   - **Magic card numbers for testing**: `4242 4242 4242 4242` (success), `4000 0000 0000 0002` (decline), `4000 0000 0000 9995` (insufficient funds).\n   - **Payment-method-specific OMS**: Card/PayPal = instant auto-capture, order created as `paid`; Bank Transfer = deferred, order created as `pending`, admin manually confirms payment, auto-cancel after configurable days.\n   - **Fulfillment guard**: Cannot create fulfillment unless `financial_status` is `paid` or `partially_refunded`.\n   - **Admin button-driven events**: \"Events (like 'shipped') are triggered by admin buttons\" - explicit admin clicks for \"Mark as shipped\" and \"Mark as delivered\".\n   - **Auto-fulfillment for digital products**: When payment is confirmed and all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment.\n   - **Explicit request**: \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\" - update all 9 spec files.\n   - User also said \"don't change specs yet\" earlier, then later explicitly asked to adjust them.\n\n2. Key Technical Concepts:\n   - **Laravel 12** with Livewire v4, Flux UI Free, Tailwind CSS v4, SQLite (WAL mode)\n   - **Multi-tenant architecture**: Organizations -> Stores, store-scoped data via global query scopes\n   - **Mock PSP (MockPaymentProvider)**: Replaces Stripe/PayPal - no external API calls, no webhooks\n   - **3 Payment Methods**: `credit_card`, `paypal`, `bank_transfer` with `App\\Enums\\PaymentMethod` enum\n   - **Payment-method-specific OMS workflows** with different inventory handling\n   - **Fulfillment Guard**: Prevents creating fulfillments when `financial_status` is not `paid` or `partially_refunded`\n   - **Auto-fulfillment for digital products** on payment confirmation\n   - **Auto-cancel job** (`CancelUnpaidBankTransferOrders`): Cancels unpaid bank transfer orders after configurable days (default 7)\n   - **Magic card numbers** for test scenarios (success, decline, insufficient funds)\n   - **Pricing**: All amounts in minor units (cents), EUR currency, 19% tax (manual mode, tax-inclusive)\n   - **Inventory lifecycle**: Reserve -> Commit (for card/paypal) or Reserve -> Admin Confirm -> Commit (for bank transfer)\n\n3. Files and Code Sections:\n   - **specs/01-DATABASE-SCHEMA.md** (COMPLETED edits)\n     - Foundational schema changes for payment method support\n     - Added `payment_method` TEXT column to `checkouts` table (nullable, CHECK IN credit_card/paypal/bank_transfer)\n     - Added `payment_method` TEXT column to `orders` table (NOT NULL, CHECK IN credit_card/paypal/bank_transfer) - placed before `status`\n     - Changed `payments.provider` from `CHECK IN ('stripe', 'paypal')` to `CHECK IN ('mock')` with default `'mock'`\n     - Added `payments.method` TEXT column (NOT NULL, CHECK IN credit_card/paypal/bank_transfer)\n     - Added `idx_payments_method` index\n     - Updated ER diagram to include `payment_method` on orders and `method` on payments\n\n   - **specs/05-BUSINESS-LOGIC.md** (COMPLETED edits)\n     - Most extensive changes - the core business logic for payments and OMS\n     - **Section 10 replaced entirely**: Removed Stripe integration (10.2), PayPal stub (10.3), webhook processing (10.5). Replaced with:\n       - 10.1 Mock Payment Provider Architecture\n       - 10.2 Supported Payment Methods table (credit_card/paypal/bank_transfer with capture timing)\n       - 10.3 Magic Card Numbers table\n       - 10.4 Mock Payment Flow sequence diagram (no external calls)\n       - 10.5 Payment State Machine (simplified: pending/captured/failed/refunded)\n       - 10.6 Bank Transfer Instructions (mock bank details)\n       - 10.7 Admin: Confirm Payment (bank transfer flow with 7 steps)\n       - 10.8 Auto-Cancel Unpaid Bank Transfer Orders (CancelUnpaidBankTransferOrders job)\n     - **Section 11.1 updated**: Added payment-method-specific order creation table\n     - **Section 11.5 replaced**: Added fulfillment guard table, updated createFulfillment procedure with guard check, replaced fulfillment status transitions with explicit admin button actions (\"Mark as shipped\", \"Mark as delivered\")\n     - **Section 11.6 added**: Inventory Behavior per Payment Method table\n     - **Section 11.7 added**: Auto-Fulfillment for Digital Products\n     - **Section 6.2 updated**: Replaced `beginPayment()` with `selectPaymentMethod()`, rewrote `completeCheckout` procedure with payment-method-specific logic (steps 2-4 determine order/financial/payment status based on payment method, step 8 conditionally commits inventory, step 12 auto-fulfills digital products)\n     - **Enum registry updated**: Replaced `PaymentStatus` (removed RequiresAction/Authorized, added Pending) and `PaymentProviderType` with `PaymentMethod`\n     - **Event registry updated**: Added `OrderPaid`, `FulfillmentDelivered`, `OrderCancelled`\n     - **Scheduled Jobs updated**: Added `CancelUnpaidBankTransferOrders`, removed `ProcessStripeWebhook`/`ProcessPayPalWebhook`\n     - **Service registry updated**: Replaced StripePaymentProvider/PayPalPaymentProvider with MockPaymentProvider\n     - **Contracts updated**: PaymentProvider implemented only by MockPaymentProvider\n     - **Value Objects updated**: Replaced `PaymentSession` with `PaymentResult` (success, referenceId, status, errorCode, errorMessage)\n\n   - **specs/02-API-ROUTES.md** (PARTIALLY COMPLETED edits)\n     - Replaced checkout flow sequence diagram: removed Stripe/PayPal/webhook actors, added MockPSP with three alt paths (instant capture, decline, bank transfer deferred)\n     - Replaced Section 2.3 entirely: Removed three endpoints (POST stripe/checkout-session, POST stripe/payment-intent, POST paypal/create-order). Added single endpoint:\n       - `POST /api/storefront/v1/checkouts/{checkoutId}/pay`\n       - Three request body variants (credit_card with card fields, paypal, bank_transfer)\n       - Two success response variants (instant capture vs bank transfer with instructions)\n       - Error responses with magic card number error codes\n\n   - **specs/03-ADMIN-UI.md** (READ lines 900-1150, NOT YET EDITED)\n     - Order detail section (lines 936-1107) with current fulfillment/refund modals\n     - Needs: \"Confirm Payment\" button, fulfillment guard messaging, \"Mark as shipped\"/\"Mark as delivered\" buttons on fulfillment cards, payment_method display\n\n   - **specs/04-STOREFRONT-UI.md** (READ lines 900-1149, NOT YET EDITED)\n     - Checkout Step 4 (Payment) at lines 1042-1049 currently references Stripe/PayPal redirect\n     - Needs: Replace with payment method selection radio buttons + onsite forms (card fields, PayPal button, bank transfer info)\n\n   - **specs/07-SEEDERS-AND-TEST-DATA.md** (READ lines 1-200, 300-400, 1200-1900, NOT YET EDITED)\n     - 15 orders for Acme Fashion (#1001-#1015), all currently using `stripe` as provider\n     - PaymentFactory at lines ~385 area\n     - Needs: Add `payment_method` to all orders (most as credit_card), add a bank_transfer order, add a paypal order, update PaymentFactory provider to 'mock'\n\n   - **specs/08-PLAYWRIGHT-E2E-PLAN.md** (READ lines 1-800, NOT YET EDITED)\n     - Suite 9 (Checkout Flow) tests at 8 tests\n     - Needs: Update for payment method selection, add tests for each payment method, magic card decline, bank transfer admin confirmation\n\n   - **specs/09-IMPLEMENTATION-ROADMAP.md** (READ lines 1-800, NOT YET EDITED)\n     - Phase 5 (Payments, Orders, Fulfillment) at lines 599-724\n     - Currently references StripePaymentProvider and PayPal stub\n     - Needs: Update to use MockPaymentProvider, add fulfillment guard, payment-method-specific OMS\n\n4. Errors and fixes:\n   - No errors were encountered during this conversation's editing session. All edits applied successfully.\n   - From the previous compacted conversation: file-too-large errors when reading 02-API-ROUTES.md (exceeded 25000 token limit), fixed by reading with offset/limit parameters.\n\n5. Problem Solving:\n   - Identified comprehensive gaps in the original spec: no pending payment state for bank transfer, no admin confirm-payment action, no auto-cancel for unpaid orders, no fulfillment guard, no auto-fulfillment for digital products, no differentiation in OMS per payment/shipment method.\n   - Designed a complete solution covering all gaps with payment-method-specific workflows.\n   - Systematically reading all affected sections across 9 spec files before making changes to ensure consistency.\n\n6. All user messages:\n   - Previous conversation (compacted): \"Write a summary of the specs\"\n   - Previous conversation (compacted): \"Describe the functionality as user stories (not epic by epic; the big picture)\"\n   - Previous conversation (compacted): \"Do we cover a seed data for a demoshop? If yes, what kind of products and how many? Are all capabilities of our shop covered by the demo data? And PSP is dummy, right? What payment methods and what shipment methods?\"\n   - Previous conversation (compacted): \"ok, I want an onsite checkout incl paymnt selection. Fake pre-auth and capture. No real PSP interation. It must fully work with a PSP mock. Do we have a order management system? How is the flow? What idscounts do we have in demo data?\"\n   - Previous conversation (compacted): [AskUserQuestion answers]: Card + PayPal + Bank Transfer; Yes magic numbers; Auto-capture on checkout\n   - Previous conversation (compacted): \"don't change specs yet\"\n   - Previous conversation (compacted): \"OMS workflow per payment method and shipment method is a bit different. do we support this?\"\n   - Previous conversation (compacted): \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\"\n   - Previous conversation (compacted): \"Events (like 'shipped') are triggerd by admin buttnos\"\n   - Current conversation: (continuation prompt) \"Please continue the conversation from where we left off without asking the user any further questions. Continue with the last task that you were asked to work on.\"\n\n7. Pending Tasks:\n   - **Task #3 (IN PROGRESS)**: Update 02-API-ROUTES.md - the main payment endpoint replacement is done, but there may be additional references to Stripe/PayPal webhook endpoints elsewhere in the file that need updating\n   - **Not yet created/started**:\n     - Update 03-ADMIN-UI.md - Add \"Confirm Payment\" button (bank_transfer + pending), fulfillment guard callout, \"Mark as shipped\"/\"Mark as delivered\" buttons on fulfillment cards, show payment_method in payment details\n     - Update 04-STOREFRONT-UI.md - Replace checkout Step 4 with payment method selection (radio: Credit Card / PayPal / Bank Transfer) + onsite forms (fake card fields, PayPal button, bank transfer details)\n     - Update 07-SEEDERS-AND-TEST-DATA.md - Add `payment_method` to all 15+3 seeded orders, change most to credit_card, add bank_transfer order (pending payment), add paypal order, update PaymentFactory provider to 'mock', update OrderFactory to include payment_method\n     - Update 08-PLAYWRIGHT-E2E-PLAN.md - Update checkout flow tests for payment method selection, add tests for each payment method, magic card decline test, bank transfer admin confirmation test\n     - Update 09-IMPLEMENTATION-ROADMAP.md - Update Phase 5 to use MockPaymentProvider, remove Stripe/PayPal references, add fulfillment guard and payment-method-specific OMS phases\n\n8. Current Work:\n   Was actively editing spec files in sequence. Had just completed the main edit for Task #3 (02-API-ROUTES.md - replacing the payment endpoints section). Was about to continue to the remaining 5 spec files (03-ADMIN-UI.md, 04-STOREFRONT-UI.md, 07-SEEDERS-AND-TEST-DATA.md, 08-PLAYWRIGHT-E2E-PLAN.md, 09-IMPLEMENTATION-ROADMAP.md).\n\n9. Optional Next Step:\n   Continue editing the remaining 5 spec files, starting with **specs/03-ADMIN-UI.md** to add the \"Confirm Payment\" button for bank transfer orders, the fulfillment guard messaging, and the \"Mark as shipped\"/\"Mark as delivered\" buttons. This is directly in line with the user's request: \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\" and the continuation prompt: \"Please continue the conversation from where we left off without asking the user any further questions. Continue with the last task that you were asked to work on.\"\n\n   The specific remaining edits needed:\n   1. **03-ADMIN-UI.md**: In the Order Detail section (around lines 936-1107), add \"Confirm Payment\" button (visible when payment_method=bank_transfer AND financial_status=pending), add fulfillment guard callout, add \"Mark as shipped\" and \"Mark as delivered\" buttons on fulfillment cards, show payment_method in payment details section\n   2. **04-STOREFRONT-UI.md**: Replace Step 4 (Payment) at lines 1042-1049 with payment method selection + onsite forms\n   3. **07-SEEDERS-AND-TEST-DATA.md**: Add payment_method to all orders, add bank transfer and paypal orders\n   4. **08-PLAYWRIGHT-E2E-PLAN.md**: Update checkout tests for new payment flow\n   5. **09-IMPLEMENTATION-ROADMAP.md**: Update Phase 5 and related phases\n\nIf you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/fabianwesner/.claude/projects/-Users-fabianwesner-Herd-shop/17a2a457-7a36-4d51-927a-aa26d07a423b.jsonl\nPlease continue the conversation from where we left off without asking the user any further questions. Continue with the last task that you were asked to work on."},"isVisibleInTranscriptOnly":true,"isCompactSummary":true,"uuid":"11c73e9b-5e24-4ff6-a086-e5ff38bcf061","timestamp":"2026-02-11T15:18:56.967Z"}
{"parentUuid":"11c73e9b-5e24-4ff6-a086-e5ff38bcf061","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PCXypSU26XgE7EhbayFovn","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26734,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26734},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJ5dk6TLev6p32HS2s","type":"assistant","uuid":"b13171ba-c376-45f9-a6db-76b24c950591","timestamp":"2026-02-11T15:18:59.286Z"}
{"parentUuid":"b13171ba-c376-45f9-a6db-76b24c950591","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PCXypSU26XgE7EhbayFovn","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"I need to continue updating the remaining spec files. Let me first check the task list state and then read the files I need to edit. I'll start by reading the files I need to modify: 03-ADMIN-UI.md, 04-STOREFRONT-UI.md, 07-SEEDERS-AND-TEST-DATA.md, 08-PLAYWRIGHT-E2E-PLAN.md, and 09-IMPLEMENTATION-ROADMAP.md.\n\nLet me first check where I left off with 02-API-ROUTES.md (Task #3) and then read the remaining files.","signature":"EsYECkYICxgCKkCpRZB/Kt41oo9p4YHv4jwClJ6GoYQQqpZtk27On5HReLNrzjo3jXC5v2TlR0fK9G+91QbWx+5ymXKSJypCAf1IEgxm9UKq2Vw9TCkJNV4aDO2fU5w2o6FU2qf7zyIwV5rAmY8PRNzAsCVaUvMT5HWxNHsAwkHFf79ORPJ6W80jmXmoxVJCPs1yiQT8Bpg5Kq0DexeceNa2TZW9JdUtEseubxF5yx5rZ/wp5czN2EFviOEJx5CEyU7so3QRcqLyu9PoG4TALwK2PK4No8oYy2LTJAHH/doT8b8lHzctm6ycjNoImM08aw7hjC+EgsnbyzPg5XZptMNCY77CMArbUQCQml23jKKJfdIsmyGGK1lh74eFWAHYrDOsQDrnSZ/jIxAwQC3unoKVC5H9MGfvrqZTxCmN12+aaBO4hbMIoLV9kFNdaCi7vXayODDw+eWwaNnXfEHSHIglkvIBEVPyYJQTDGgERzaVHNFym6rg2f3U72RZKMFrl+SEBsI13OpJWVa/xa8DDwnYhnmgq6Pj16MQRS4k5A/8V8IxBGAN+UCIJaxWKSTq425L+zenH+Uoql8U3KOX4imbkcLU1BgBqLoJyWxzOwGw1lsCBziEHpBBKvppTvwNFicmvRic6o3Gw1xfVg7mV3PlwIg0uh1Qv8wpg5QrK8j1R2wN3BPFB32PVwNscyMm/aW7bmzT8VX4uUmt0v5+qDhPz5ZKz4PnAWvVBWxHlCQw/HxVmpQbOCxxB9zv0C6b9MyZkFf+lO2NGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26734,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26734},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJ5dk6TLev6p32HS2s","type":"assistant","uuid":"b7040c93-effd-478a-9f90-fc0adaee0cf8","timestamp":"2026-02-11T15:19:01.677Z"}
{"parentUuid":"b7040c93-effd-478a-9f90-fc0adaee0cf8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PCXypSU26XgE7EhbayFovn","type":"message","role":"assistant","content":[{"type":"text","text":"I'll continue updating the remaining spec files. Let me first check the task list and read the files I need to edit."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26734,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26734},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJ5dk6TLev6p32HS2s","type":"assistant","uuid":"be2cfb2d-346a-4947-8828-989adbb07f71","timestamp":"2026-02-11T15:19:02.202Z"}
{"parentUuid":"be2cfb2d-346a-4947-8828-989adbb07f71","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PCXypSU26XgE7EhbayFovn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E84wiJXz9JFm4rrqr8PYXj","name":"TaskList","input":{}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26734,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26734},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJ5dk6TLev6p32HS2s","type":"assistant","uuid":"08fb19c2-c105-46fb-914a-2f38ebcca8bd","timestamp":"2026-02-11T15:19:02.409Z"}
{"parentUuid":"08fb19c2-c105-46fb-914a-2f38ebcca8bd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E84wiJXz9JFm4rrqr8PYXj","type":"tool_result","content":"#2 [completed] Update 05-BUSINESS-LOGIC.md - Mock PSP and payment-method-specific OMS\n#3 [in_progress] Update 02-API-ROUTES.md - Replace payment endpoints with mock flow"}]},"uuid":"d5193836-4f91-4cec-88c0-c5eb8ac2d78a","timestamp":"2026-02-11T15:19:02.414Z","toolUseResult":{"tasks":[{"id":"2","subject":"Update 05-BUSINESS-LOGIC.md - Mock PSP and payment-method-specific OMS","status":"completed","blockedBy":[]},{"id":"3","subject":"Update 02-API-ROUTES.md - Replace payment endpoints with mock flow","status":"in_progress","blockedBy":[]}]},"sourceToolAssistantUUID":"08fb19c2-c105-46fb-914a-2f38ebcca8bd"}
{"parentUuid":"d5193836-4f91-4cec-88c0-c5eb8ac2d78a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PCXypSU26XgE7EhbayFovn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017dN9aeCZ2aRasyFYzSAckK","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":1,"limit":50}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26734,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26734},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJ5dk6TLev6p32HS2s","type":"assistant","uuid":"f740c321-c579-4d44-b02f-8c353f8c5121","timestamp":"2026-02-11T15:19:03.313Z"}
{"parentUuid":"f740c321-c579-4d44-b02f-8c353f8c5121","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_017dN9aeCZ2aRasyFYzSAckK","toolUseID":"toolu_017dN9aeCZ2aRasyFYzSAckK","timestamp":"2026-02-11T15:19:03.321Z","uuid":"362734e9-79b8-48f9-b15f-9ff2cbd8c354"}
{"parentUuid":"f740c321-c579-4d44-b02f-8c353f8c5121","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017dN9aeCZ2aRasyFYzSAckK","type":"tool_result","content":"     1→# API and Routing Specification\n     2→\n     3→This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n     4→\n     5→All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n     6→\n     7→---\n     8→\n     9→## 1. Web Routes (Blade/Livewire Pages)\n    10→\n    11→### 1.1 Admin Auth Routes\n    12→\n    13→No middleware beyond the default `web` group. These routes handle admin login and logout.\n    14→\n    15→| Method | Path | Description | Component / Action |\n    16→|--------|------|-------------|--------------------|\n    17→| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n    18→| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n    19→| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n    20→| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n    21→| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n    22→| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n    23→| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n    24→\n    25→Rate limit: `login` tier (5 per minute per IP).\n    26→\n    27→---\n    28→\n    29→### 1.2 Admin Routes\n    30→\n    31→**Prefix:** `/admin`\n    32→**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n    33→\n    34→All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n    35→\n    36→> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n    37→\n    38→#### Dashboard\n    39→\n    40→| Method | Path | Description | Component |\n    41→|--------|------|-------------|-----------|\n    42→| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n    43→\n    44→#### Products\n    45→\n    46→| Method | Path | Description | Component |\n    47→|--------|------|-------------|-----------|\n    48→| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n    49→| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n    50→| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |"}]},"uuid":"f5786255-1569-48e9-a890-134161fb4b4e","timestamp":"2026-02-11T15:19:03.320Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |","numLines":50,"startLine":1,"totalLines":3336}},"sourceToolAssistantUUID":"f740c321-c579-4d44-b02f-8c353f8c5121"}
{"parentUuid":"f5786255-1569-48e9-a890-134161fb4b4e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PCXypSU26XgE7EhbayFovn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012rQWsqp4z4M2b7UTGucTLe","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26734,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26734},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJ5dk6TLev6p32HS2s","type":"assistant","uuid":"961d4f70-5c2f-491f-9eaa-ee934630e2d3","timestamp":"2026-02-11T15:19:03.770Z"}
{"parentUuid":"961d4f70-5c2f-491f-9eaa-ee934630e2d3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PCXypSU26XgE7EhbayFovn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0129T3JgC9FoPK4YBG4RsEmp","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":26734,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26734},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJ5dk6TLev6p32HS2s","type":"assistant","uuid":"f50c2a6d-4c79-493c-b85b-821629ef6773","timestamp":"2026-02-11T15:19:04.287Z"}
{"parentUuid":"961d4f70-5c2f-491f-9eaa-ee934630e2d3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File content (26427 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_012rQWsqp4z4M2b7UTGucTLe"}]},"uuid":"f6288145-12a1-44b8-a9e8-cdeb2a263d40","timestamp":"2026-02-11T15:19:04.294Z","toolUseResult":"Error: File content (26427 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":"961d4f70-5c2f-491f-9eaa-ee934630e2d3"}
{"parentUuid":"f50c2a6d-4c79-493c-b85b-821629ef6773","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>Sibling tool call errored</tool_use_error>","is_error":true,"tool_use_id":"toolu_0129T3JgC9FoPK4YBG4RsEmp"}]},"uuid":"ae1e4489-b8e3-4909-9479-8042ea81813a","timestamp":"2026-02-11T15:19:04.566Z","toolUseResult":"Sibling tool call errored","sourceToolAssistantUUID":"f50c2a6d-4c79-493c-b85b-821629ef6773"}
{"parentUuid":"ae1e4489-b8e3-4909-9479-8042ea81813a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GYbYQgjqUyKEw4E3TVK9MM","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The 03-ADMIN-UI.md and 04-STOREFRONT-UI.md files are too large to read at once. Let me read them in sections, focusing on the areas I need to edit. Let me also check if there are remaining Stripe/PayPal references in 02-API-ROUTES.md that need updating.","signature":"EqYDCkYICxgCKkAqmPjXlNEMbDPbktpJtU4sI1Sdp7c6dsJ9N0WdJ5wzzK0yF7aDo2ZMXFn5Idwjgi0sE5DK2ClZRdhrA04ALYMdEgxxEVvoEnYxxtLN5usaDPxrmFbonU7wD12f1yIwLGUdryeVnj1NMmcxgomPDyBuDuaYtzx7LhS+SyhV2H4FLH7L4H4TshYasK+tu3pFKo0CeR/qRyr3lVkPyvWR1EdKjR3I9koTYdunPiq8IIcKOlgOJXpEOLW9RC5gBc7HLuutlC5fgTNExZdbcgCcJFNLuaE0cla3IjaNh53Yb27RaWTtIHHvHJyMdYLdAWyTNpbC5SeuLXyFwA8novprqkz4iYWM/KeV5ftO1GVD9RkBmL4QO/CUsnrPUhNEoDVmV1TKKV1HoOY2W1w+u9n7zTaaj0aeYXuRe1AReqCfUwYVjPZdMEQT+Nra7hzc+uLMXVlx7Dj63N9HHmbg5ucvFrGDBCQ/uINkNWappOC6o+xgZlB7XfYC/8jBJTfGDcS9JxH2r2tqHLkheJStScM6F9wv08KNXoBTK6Ro83jq8DUYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1698,"cache_read_input_tokens":47420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1698},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJe86eqBHZnmvMgNt7","type":"assistant","uuid":"7a9ae7ba-079b-46fc-8163-84c8bbb76e1b","timestamp":"2026-02-11T15:19:08.969Z"}
{"parentUuid":"7a9ae7ba-079b-46fc-8163-84c8bbb76e1b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GYbYQgjqUyKEw4E3TVK9MM","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the files in sections and check for remaining Stripe/PayPal references."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1698,"cache_read_input_tokens":47420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1698},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJe86eqBHZnmvMgNt7","type":"assistant","uuid":"71a277e8-2b9a-44a7-8895-22f283bfe1ae","timestamp":"2026-02-11T15:19:09.361Z"}
{"parentUuid":"71a277e8-2b9a-44a7-8895-22f283bfe1ae","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GYbYQgjqUyKEw4E3TVK9MM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018EFztWe9KvmVY9HR4vtBZx","name":"Grep","input":{"pattern":"(?i)(stripe|paypal|webhook)","path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","output_mode":"content"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1698,"cache_read_input_tokens":47420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1698},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJe86eqBHZnmvMgNt7","type":"assistant","uuid":"8e2669b8-adb1-4905-b60c-3b74805ddd0d","timestamp":"2026-02-11T15:19:10.461Z"}
{"parentUuid":"8e2669b8-adb1-4905-b60c-3b74805ddd0d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_018EFztWe9KvmVY9HR4vtBZx","toolUseID":"toolu_018EFztWe9KvmVY9HR4vtBZx","timestamp":"2026-02-11T15:19:10.736Z","uuid":"5983dc62-48e4-40a1-8f9b-5c989d1b3724"}
{"parentUuid":"8e2669b8-adb1-4905-b60c-3b74805ddd0d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018EFztWe9KvmVY9HR4vtBZx","type":"tool_result","content":"122:| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n130:> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n443:    alt Credit Card / PayPal (instant capture)\n804:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n821:**Request body (PayPal):**\n825:  \"payment_method\": \"paypal\"\n839:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n845:**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n1946:        \"provider\": \"stripe\",\n2422:  \"provider\": \"stripe_tax\",\n2425:    \"stripe_tax_settings_id\": \"txr_abc123\"\n2433:| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n2850:## 4. Webhook Receiver Endpoints\n2852:These endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n2854:**Prefix:** `/api/webhooks`\n2859:### POST /api/webhooks/stripe\n2861:Receive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n2863:- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n2864:- **Rate limit:** `webhooks` (100/min per IP)\n2870:| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n2895:**Webhook processing steps:**\n2897:1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n2901:**Supported Stripe event types and actions:**\n2923:### POST /api/webhooks/paypal\n2925:Receive PayPal webhook notifications.\n2927:- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n2928:- **Rate limit:** `webhooks` (100/min per IP)\n2934:| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n2935:| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n2936:| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n2937:| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n2938:| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n2960:**Webhook processing steps:**\n2962:1. Verify signature by calling PayPal's verification endpoint\n2967:**Supported PayPal event types and actions:**\n3131:#### Webhooks\n3133:Applied to `/api/webhooks/*` routes:\n3137:| `throttle:webhooks` | Rate limiting (100/min per IP) |\n3181:| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n3225:| `POST /api/webhooks/stripe` | `webhooks` |\n3226:| `POST /api/webhooks/paypal` | `webhooks` |\n3240:    payment_pending --> completed : Webhook confirms successful payment\n3256:| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n3269:- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version."}]},"uuid":"4adbca37-3f68-485b-9463-8e7952008a0f","timestamp":"2026-02-11T15:19:10.735Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"122:| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n130:> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n443:    alt Credit Card / PayPal (instant capture)\n804:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n821:**Request body (PayPal):**\n825:  \"payment_method\": \"paypal\"\n839:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n845:**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n1946:        \"provider\": \"stripe\",\n2422:  \"provider\": \"stripe_tax\",\n2425:    \"stripe_tax_settings_id\": \"txr_abc123\"\n2433:| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n2850:## 4. Webhook Receiver Endpoints\n2852:These endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n2854:**Prefix:** `/api/webhooks`\n2859:### POST /api/webhooks/stripe\n2861:Receive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n2863:- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n2864:- **Rate limit:** `webhooks` (100/min per IP)\n2870:| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n2895:**Webhook processing steps:**\n2897:1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n2901:**Supported Stripe event types and actions:**\n2923:### POST /api/webhooks/paypal\n2925:Receive PayPal webhook notifications.\n2927:- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n2928:- **Rate limit:** `webhooks` (100/min per IP)\n2934:| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n2935:| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n2936:| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n2937:| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n2938:| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n2960:**Webhook processing steps:**\n2962:1. Verify signature by calling PayPal's verification endpoint\n2967:**Supported PayPal event types and actions:**\n3131:#### Webhooks\n3133:Applied to `/api/webhooks/*` routes:\n3137:| `throttle:webhooks` | Rate limiting (100/min per IP) |\n3181:| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n3225:| `POST /api/webhooks/stripe` | `webhooks` |\n3226:| `POST /api/webhooks/paypal` | `webhooks` |\n3240:    payment_pending --> completed : Webhook confirms successful payment\n3256:| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n3269:- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.","numLines":45},"sourceToolAssistantUUID":"8e2669b8-adb1-4905-b60c-3b74805ddd0d"}
{"parentUuid":"4adbca37-3f68-485b-9463-8e7952008a0f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GYbYQgjqUyKEw4E3TVK9MM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RqdSx7Q8QPGvDSbRzppsFN","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","offset":1,"limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1698,"cache_read_input_tokens":47420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1698},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJe86eqBHZnmvMgNt7","type":"assistant","uuid":"d4d49334-e6cc-46cf-903f-818c23fbcc5b","timestamp":"2026-02-11T15:19:10.984Z"}
{"parentUuid":"d4d49334-e6cc-46cf-903f-818c23fbcc5b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01RqdSx7Q8QPGvDSbRzppsFN","toolUseID":"toolu_01RqdSx7Q8QPGvDSbRzppsFN","timestamp":"2026-02-11T15:19:10.986Z","uuid":"d6d0868c-606a-4dc9-83ff-f08e5912469d"}
{"parentUuid":"d4d49334-e6cc-46cf-903f-818c23fbcc5b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RqdSx7Q8QPGvDSbRzppsFN","type":"tool_result","content":"     1→# 03 - Admin Panel UI/UX Specification\n     2→\n     3→This document defines every view, component, and interaction for the Shopify-clone admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.\n     4→\n     5→**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip\n     6→\n     7→Anything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.\n     8→\n     9→---\n    10→\n    11→## 1. Admin Layout Shell\n    12→\n    13→The admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.\n    14→\n    15→### 1.1 Route Prefix\n    16→\n    17→All admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.\n    18→\n    19→### 1.2 Sidebar Navigation\n    20→\n    21→**Livewire component:** `App\\Livewire\\Admin\\Layout\\Sidebar`\n    22→\n    23→**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`\n    24→\n    25→#### Component Requirements\n    26→\n    27→| Property | Type | Purpose |\n    28→|----------|------|---------|\n    29→| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |\n    30→| `currentRoute` | `string` | The current route name for active highlighting |\n    31→\n    32→| Action | Behavior |\n    33→|--------|----------|\n    34→| `toggle()` | Toggles the `collapsed` state |\n    35→\n    36→#### Wireframe\n    37→\n    38→```\n    39→+-------------------------------+\n    40→|  [BRAND LOGO]  Platform Name  |\n    41→|-------------------------------|\n    42→|  [separator]                  |\n    43→|                               |\n    44→|  [chart-bar]    Dashboard     |\n    45→|                               |\n    46→|  PRODUCTS                     |\n    47→|  [cube]         Products      |\n    48→|  [rect-stack]   Collections   |\n    49→|  [archive-box]  Inventory     |\n    50→|                               |\n    51→|  [separator]                  |\n    52→|  ORDERS                       |\n    53→|  [shopping-bag] Orders        |\n    54→|                               |\n    55→|  [separator]                  |\n    56→|  CUSTOMERS                    |\n    57→|  [users]        Customers     |\n    58→|                               |\n    59→|  [separator]                  |\n    60→|  DISCOUNTS                    |\n    61→|  [tag]          Discounts     |\n    62→|                               |\n    63→|  [separator]                  |\n    64→|  CONTENT                      |\n    65→|  [doc-text]     Pages         |\n    66→|  [bars-3]       Navigation    |\n    67→|  [paint-brush]  Themes        |\n    68→|                               |\n    69→|  [separator]                  |\n    70→|  [chart-pie]    Analytics     |\n    71→|  [cog-6-tooth]  Settings      |\n    72→|  [squares-2x2]  Apps          |\n    73→|  [code-bracket] Developers    |\n    74→+-------------------------------+\n    75→```\n    76→\n    77→#### Navigation Structure\n    78→\n    79→```\n    80→Dashboard                        -> admin.dashboard\n    81→Products (group label)\n    82→  Products                       -> admin.products.index\n    83→  Collections                    -> admin.collections.index\n    84→  Inventory                      -> admin.inventory.index\n    85→Orders\n    86→  Orders                         -> admin.orders.index\n    87→Customers\n    88→  Customers                      -> admin.customers.index\n    89→Discounts\n    90→  Discounts                      -> admin.discounts.index\n    91→Content (group label)\n    92→  Pages                          -> admin.pages.index\n    93→  Navigation                     -> admin.navigation.index\n    94→  Themes                         -> admin.themes.index\n    95→Analytics                        -> admin.analytics.index\n    96→Settings                         -> admin.settings.index\n    97→Apps                             -> admin.apps.index\n    98→Developers                       -> admin.developers.index\n    99→```\n   100→"}]},"uuid":"407bc339-34f6-45b3-929d-d3575205c918","timestamp":"2026-02-11T15:19:10.986Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","content":"# 03 - Admin Panel UI/UX Specification\n\nThis document defines every view, component, and interaction for the Shopify-clone admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.\n\n**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip\n\nAnything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.\n\n---\n\n## 1. Admin Layout Shell\n\nThe admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.\n\n### 1.1 Route Prefix\n\nAll admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.\n\n### 1.2 Sidebar Navigation\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\Sidebar`\n\n**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |\n| `currentRoute` | `string` | The current route name for active highlighting |\n\n| Action | Behavior |\n|--------|----------|\n| `toggle()` | Toggles the `collapsed` state |\n\n#### Wireframe\n\n```\n+-------------------------------+\n|  [BRAND LOGO]  Platform Name  |\n|-------------------------------|\n|  [separator]                  |\n|                               |\n|  [chart-bar]    Dashboard     |\n|                               |\n|  PRODUCTS                     |\n|  [cube]         Products      |\n|  [rect-stack]   Collections   |\n|  [archive-box]  Inventory     |\n|                               |\n|  [separator]                  |\n|  ORDERS                       |\n|  [shopping-bag] Orders        |\n|                               |\n|  [separator]                  |\n|  CUSTOMERS                    |\n|  [users]        Customers     |\n|                               |\n|  [separator]                  |\n|  DISCOUNTS                    |\n|  [tag]          Discounts     |\n|                               |\n|  [separator]                  |\n|  CONTENT                      |\n|  [doc-text]     Pages         |\n|  [bars-3]       Navigation    |\n|  [paint-brush]  Themes        |\n|                               |\n|  [separator]                  |\n|  [chart-pie]    Analytics     |\n|  [cog-6-tooth]  Settings      |\n|  [squares-2x2]  Apps          |\n|  [code-bracket] Developers    |\n+-------------------------------+\n```\n\n#### Navigation Structure\n\n```\nDashboard                        -> admin.dashboard\nProducts (group label)\n  Products                       -> admin.products.index\n  Collections                    -> admin.collections.index\n  Inventory                      -> admin.inventory.index\nOrders\n  Orders                         -> admin.orders.index\nCustomers\n  Customers                      -> admin.customers.index\nDiscounts\n  Discounts                      -> admin.discounts.index\nContent (group label)\n  Pages                          -> admin.pages.index\n  Navigation                     -> admin.navigation.index\n  Themes                         -> admin.themes.index\nAnalytics                        -> admin.analytics.index\nSettings                         -> admin.settings.index\nApps                             -> admin.apps.index\nDevelopers                       -> admin.developers.index\n```\n","numLines":100,"startLine":1,"totalLines":2403}},"sourceToolAssistantUUID":"d4d49334-e6cc-46cf-903f-818c23fbcc5b"}
{"parentUuid":"407bc339-34f6-45b3-929d-d3575205c918","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GYbYQgjqUyKEw4E3TVK9MM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YDYw1deTPGR1TWayeEoWQc","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","offset":900,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1698,"cache_read_input_tokens":47420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1698},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJe86eqBHZnmvMgNt7","type":"assistant","uuid":"77822767-6ac7-4d9b-a8a8-a23a3243be89","timestamp":"2026-02-11T15:19:11.749Z"}
{"parentUuid":"77822767-6ac7-4d9b-a8a8-a23a3243be89","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01YDYw1deTPGR1TWayeEoWQc","toolUseID":"toolu_01YDYw1deTPGR1TWayeEoWQc","timestamp":"2026-02-11T15:19:11.754Z","uuid":"a2a40cc3-8740-4137-8dbe-c8f9f5328ad6"}
{"parentUuid":"77822767-6ac7-4d9b-a8a8-a23a3243be89","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YDYw1deTPGR1TWayeEoWQc","type":"tool_result","content":"   900→|----------|----------------|-----------|-----------|---------|--------|\n   901→| #1001    | Jan 15, 2:30 PM| John Doe  | Paid      | Unful.  | $131.36|\n   902→| #1000    | Jan 14, 9:15 AM| Guest     | Pending   | Unful.  | $45.00 |\n   903→+------------------------------------------------------------------+\n   904→| < 1 2 3 >                                                        |\n   905→+------------------------------------------------------------------+\n   906→```\n   907→\n   908→### Filter Tabs\n   909→\n   910→A horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.\n   911→\n   912→### Table Column Definitions\n   913→\n   914→| Column | Content |\n   915→|--------|---------|\n   916→| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n   917→| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n   918→| Customer | Customer name or \"Guest\" |\n   919→| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n   920→| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n   921→| Total | Formatted currency amount |\n   922→\n   923→Pagination below the table.\n   924→\n   925→### Flux Component Mapping\n   926→\n   927→| UI Element | Flux Component |\n   928→|-----------|----------------|\n   929→| Page title | `heading` |\n   930→| Search field | `input` |\n   931→| Status badges | `badge` |\n   932→| Cell text | `text` |\n   933→\n   934→---\n   935→\n   936→## 8. Order Detail\n   937→\n   938→**Route:** `GET /admin/orders/{order}`\n   939→\n   940→**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n   941→\n   942→**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n   943→\n   944→### Component Requirements\n   945→\n   946→| Property | Type | Purpose |\n   947→|----------|------|---------|\n   948→| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n   949→| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n   950→| `trackingCompany` | `string` | Tracking company for fulfillment |\n   951→| `trackingNumber` | `string` | Tracking number |\n   952→| `trackingUrl` | `string` | Tracking URL |\n   953→| `refundAmount` | `?int` | Custom refund amount |\n   954→| `refundReason` | `string` | Refund reason text |\n   955→| `refundLines` | `array` | Lines selected for refund |\n   956→\n   957→| Action | Behavior |\n   958→|--------|----------|\n   959→| `mount(Order $order)` | Loads order with all relationships |\n   960→| `createFulfillment()` | Creates a fulfillment record for selected lines |\n   961→| `createRefund()` | Creates a refund for selected lines or custom amount |\n   962→| `openFulfillmentModal()` | Opens the fulfillment modal |\n   963→| `openRefundModal()` | Opens the refund modal |\n   964→\n   965→### Two-Column Layout Wireframe\n   966→\n   967→```\n   968→+----------------------------------------------+--------------------+\n   969→|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n   970→+----------------------------------------------+--------------------+\n   971→| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n   972→| Jan 15, 2024 2:30 PM                        | John Doe           |\n   973→|                                              | john@example.com   |\n   974→| [Create fulfillment]  [Refund]               | > View customer    |\n   975→|                                              |--------------------|\n   976→| TIMELINE                                     | SHIPPING ADDRESS   |\n   977→| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n   978→| |                                            | Springfield, IL    |\n   979→| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n   980→| |                                            | United States      |\n   981→| o  (more events...)                          |--------------------|\n   982→|                                              | BILLING ADDRESS    |\n   983→| ORDER LINES                                  | 123 Main St        |\n   984→| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n   985→| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n   986→| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n   987→|                                              |                    |\n   988→|                    Subtotal     $140.00       |                    |\n   989→|                    Discount     -$12.00       |                    |\n   990→|                    Shipping      $5.00        |                    |\n   991→|                    Tax          $18.36        |                    |\n   992→|                    Total       $151.36        |                    |\n   993→|                                              |                    |\n   994→| PAYMENT DETAILS                              |                    |\n   995→| Provider: Stripe  [Paid]                     |                    |\n   996→| Amount: $151.36                              |                    |\n   997→| Payment ID: pi_xxx...                        |                    |\n   998→+----------------------------------------------+--------------------+\n   999→```\n  1000→\n  1001→### Left Column Sections\n  1002→\n  1003→#### Order Heading\n  1004→- Order number as a large heading\n  1005→- Financial status badge (color mapped: paid=green, default=zinc)\n  1006→- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n  1007→- Placed-at date as muted text\n  1008→\n  1009→#### Action Buttons\n  1010→Shown below the heading:\n  1011→- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n  1012→- \"Refund\" button (ghost variant, shown when order is paid)\n  1013→\n  1014→#### Timeline\n  1015→A vertical timeline with left border line and dot markers:\n  1016→- Each event: title text (bold) and timestamp text (small, muted)\n  1017→- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n  1018→\n  1019→#### Order Lines Table\n  1020→\n  1021→| Column | Content |\n  1022→|--------|---------|\n  1023→| Image | 40x40 product thumbnail |\n  1024→| Product | Title and SKU |\n  1025→| Quantity | Unit count |\n  1026→| Unit Price | Formatted amount |\n  1027→| Total | Line total formatted |\n  1028→\n  1029→#### Order Summary\n  1030→A right-aligned summary block below the lines table:\n  1031→- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n  1032→- Each row is a flex container with label left and amount right\n  1033→\n  1034→#### Payment Details\n  1035→A card showing:\n  1036→- Provider (Stripe/PayPal) as text\n  1037→- Status as Flux badge\n  1038→- Amount\n  1039→- Provider payment ID (linked if applicable)\n  1040→\n  1041→### Right Column Sections\n  1042→\n  1043→#### Customer Card\n  1044→- Heading \"Customer\" (size md)\n  1045→- Separator\n  1046→- Customer name (or \"Guest\")\n  1047→- Customer email (muted text)\n  1048→- \"View customer\" link (shown only if customer exists, links to customer detail page)\n  1049→\n  1050→#### Shipping Address Card\n  1051→- Heading \"Shipping address\" (size md)\n  1052→- Separator\n  1053→- Full address rendered from shipping_address_json: line1, city/state/zip, country\n  1054→\n  1055→#### Billing Address Card\n  1056→Same pattern as shipping address, using billing_address_json.\n  1057→\n  1058→### Fulfillment Modal\n  1059→\n  1060→| Property | Value |\n  1061→|----------|-------|\n  1062→| Modal name | `create-fulfillment` |\n  1063→| Max width | lg |\n  1064→| Heading | \"Create fulfillment\" |\n  1065→\n  1066→**Content:**\n  1067→- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n  1068→- Separator\n  1069→- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n  1070→- Tracking number input field\n  1071→- Tracking URL input field (type: url)\n  1072→- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n  1073→\n  1074→### Refund Modal\n  1075→\n  1076→| Property | Value |\n  1077→|----------|-------|\n  1078→| Modal name | `create-refund` |\n  1079→| Max width | lg |\n  1080→| Heading | \"Refund order\" |\n  1081→\n  1082→**Content:**\n  1083→- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n  1084→- Separator\n  1085→- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n  1086→- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n  1087→- Cancel button (ghost) and \"Create refund\" button (danger)\n  1088→\n  1089→### Flux Component Mapping\n  1090→\n  1091→| UI Element | Flux Component |\n  1092→|-----------|----------------|\n  1093→| Order number heading | `heading` |\n  1094→| Status badges | `badge` |\n  1095→| Action buttons | `button` |\n  1096→| Section headings | `heading` (size md) |\n  1097→| Section dividers | `separator` |\n  1098→| Text content | `text` |\n  1099→| Both modals | `modal` |\n  1100→| Modal form fields | `field`, `input`, `textarea` |\n  1101→| Line checkboxes | `checkbox` |\n  1102→| Modal buttons | `button` |\n  1103→| Icons | `icon` |\n  1104→| Callout messages | `callout` |\n  1105→\n  1106→---\n  1107→\n  1108→## 9. Customers List and Detail\n  1109→\n  1110→### 9.1 Customers List\n  1111→\n  1112→**Route:** `GET /admin/customers`\n  1113→\n  1114→**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n  1115→\n  1116→**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`\n  1117→\n  1118→#### Component Requirements\n  1119→\n  1120→| Property | Type | Purpose |\n  1121→|----------|------|---------|\n  1122→| `search` | `string` | Search by name or email |\n  1123→\n  1124→| Computed Property | Purpose |\n  1125→|-------------------|---------|\n  1126→| `customers` | Paginated, searched customers with `withCount('orders')` and `withSum('orders', 'total_amount')` |\n  1127→\n  1128→#### Layout\n  1129→\n  1130→- Heading \"Customers\"\n  1131→- Search input\n  1132→- Table columns: Name, Email, Orders count, Total spent (formatted), Created date\n  1133→- Each row links to customer detail\n  1134→\n  1135→### 9.2 Customer Detail\n  1136→\n  1137→**Route:** `GET /admin/customers/{customer}`\n  1138→\n  1139→**Livewire component:** `App\\Livewire\\Admin\\Customers\\Show`\n  1140→\n  1141→**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`\n  1142→\n  1143→#### Component Requirements\n  1144→\n  1145→| Property | Type | Purpose |\n  1146→|----------|------|---------|\n  1147→| `customer` | `Customer` | The customer model |\n  1148→| `editingAddress` | `?CustomerAddress` | Address currently being edited |\n  1149→| `addressLabel` | `string` | Label for address form |\n  1150→| `addressJson` | `array` | Address fields being edited |\n  1151→\n  1152→| Action | Behavior |\n  1153→|--------|----------|\n  1154→| `mount(Customer $customer)` | Loads customer with orders and addresses |\n  1155→| `openAddressForm(?CustomerAddress $address)` | Opens address modal for create/edit |\n  1156→| `saveAddress()` | Saves the address |\n  1157→| `deleteAddress(int $addressId)` | Deletes an address |\n  1158→| `setDefaultAddress(int $addressId)` | Sets address as default |\n  1159→\n  1160→#### Two-Column Layout Wireframe\n  1161→\n  1162→```\n  1163→+----------------------------------------------+--------------------+\n  1164→|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n  1165→+----------------------------------------------+--------------------+\n  1166→| CUSTOMER INFO                                | ADDRESSES          |\n  1167→| Name: Jane Smith                             | Home (default)     |\n  1168→| Email: jane@example.com                      | 123 Main St        |\n  1169→| Created: Jan 1, 2024                         | Springfield, IL    |\n  1170→| Marketing: [Opted In]                        | [Edit] [Delete]    |\n  1171→|                                              |                    |\n  1172→| ORDER HISTORY                                | Office             |\n  1173→| Order # | Date      | Status | Total         | 456 Corp Ave       |\n  1174→| #1005   | Jan 20    | Paid   | $89.00        | Chicago, IL        |\n  1175→| #1001   | Jan 15    | Paid   | $131.36       | [Edit] [Delete]    |\n  1176→| < 1 2 >                                     |                    |\n  1177→|                                              | [+ Add address]    |\n  1178→+----------------------------------------------+--------------------+\n  1179→```\n  1180→\n  1181→**Left column:**\n  1182→- Customer info card: Name, email, created date, marketing opt-in status (Flux badge)\n  1183→- Order history table: columns Order #, Date, Status, Total. Each order links to order detail. Paginated.\n  1184→\n  1185→**Right column:**\n  1186→- Addresses card: List of addresses, each showing label, full address text, default badge (if `is_default`), Edit and Delete buttons\n  1187→- \"Add address\" button opens address modal\n  1188→\n  1189→#### Address Modal\n  1190→\n  1191→| Property | Value |\n  1192→|----------|-------|\n  1193→| Modal name | `address-form` |\n  1194→| Max width | md |\n  1195→| Heading | \"Edit address\" or \"Add address\" (dynamic) |\n  1196→\n  1197→**Form fields:**\n  1198→- Label input (placeholder: \"Home, Office...\")\n  1199→- Address line 1 input"}]},"uuid":"6239f8ad-d8e3-4c85-9d3d-b0413b2670bd","timestamp":"2026-02-11T15:19:11.753Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","content":"|----------|----------------|-----------|-----------|---------|--------|\n| #1001    | Jan 15, 2:30 PM| John Doe  | Paid      | Unful.  | $131.36|\n| #1000    | Jan 14, 9:15 AM| Guest     | Pending   | Unful.  | $45.00 |\n+------------------------------------------------------------------+\n| < 1 2 3 >                                                        |\n+------------------------------------------------------------------+\n```\n\n### Filter Tabs\n\nA horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.\n\n### Table Column Definitions\n\n| Column | Content |\n|--------|---------|\n| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n| Customer | Customer name or \"Guest\" |\n| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n| Total | Formatted currency amount |\n\nPagination below the table.\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Search field | `input` |\n| Status badges | `badge` |\n| Cell text | `text` |\n\n---\n\n## 8. Order Detail\n\n**Route:** `GET /admin/orders/{order}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n\n**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `createFulfillment()` | Creates a fulfillment record for selected lines |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |\n\n### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n| Jan 15, 2024 2:30 PM                        | John Doe           |\n|                                              | john@example.com   |\n| [Create fulfillment]  [Refund]               | > View customer    |\n|                                              |--------------------|\n| TIMELINE                                     | SHIPPING ADDRESS   |\n| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n| |                                            | Springfield, IL    |\n| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n| |                                            | United States      |\n| o  (more events...)                          |--------------------|\n|                                              | BILLING ADDRESS    |\n| ORDER LINES                                  | 123 Main St        |\n| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n|                                              |                    |\n|                    Subtotal     $140.00       |                    |\n|                    Discount     -$12.00       |                    |\n|                    Shipping      $5.00        |                    |\n|                    Tax          $18.36        |                    |\n|                    Total       $151.36        |                    |\n|                                              |                    |\n| PAYMENT DETAILS                              |                    |\n| Provider: Stripe  [Paid]                     |                    |\n| Amount: $151.36                              |                    |\n| Payment ID: pi_xxx...                        |                    |\n+----------------------------------------------+--------------------+\n```\n\n### Left Column Sections\n\n#### Order Heading\n- Order number as a large heading\n- Financial status badge (color mapped: paid=green, default=zinc)\n- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n- Placed-at date as muted text\n\n#### Action Buttons\nShown below the heading:\n- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n- \"Refund\" button (ghost variant, shown when order is paid)\n\n#### Timeline\nA vertical timeline with left border line and dot markers:\n- Each event: title text (bold) and timestamp text (small, muted)\n- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n\n#### Order Lines Table\n\n| Column | Content |\n|--------|---------|\n| Image | 40x40 product thumbnail |\n| Product | Title and SKU |\n| Quantity | Unit count |\n| Unit Price | Formatted amount |\n| Total | Line total formatted |\n\n#### Order Summary\nA right-aligned summary block below the lines table:\n- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n- Each row is a flex container with label left and amount right\n\n#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)\n\n### Right Column Sections\n\n#### Customer Card\n- Heading \"Customer\" (size md)\n- Separator\n- Customer name (or \"Guest\")\n- Customer email (muted text)\n- \"View customer\" link (shown only if customer exists, links to customer detail page)\n\n#### Shipping Address Card\n- Heading \"Shipping address\" (size md)\n- Separator\n- Full address rendered from shipping_address_json: line1, city/state/zip, country\n\n#### Billing Address Card\nSame pattern as shipping address, using billing_address_json.\n\n### Fulfillment Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-fulfillment` |\n| Max width | lg |\n| Heading | \"Create fulfillment\" |\n\n**Content:**\n- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n- Separator\n- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n- Tracking number input field\n- Tracking URL input field (type: url)\n- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n\n### Refund Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-refund` |\n| Max width | lg |\n| Heading | \"Refund order\" |\n\n**Content:**\n- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n- Separator\n- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n- Cancel button (ghost) and \"Create refund\" button (danger)\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Order number heading | `heading` |\n| Status badges | `badge` |\n| Action buttons | `button` |\n| Section headings | `heading` (size md) |\n| Section dividers | `separator` |\n| Text content | `text` |\n| Both modals | `modal` |\n| Modal form fields | `field`, `input`, `textarea` |\n| Line checkboxes | `checkbox` |\n| Modal buttons | `button` |\n| Icons | `icon` |\n| Callout messages | `callout` |\n\n---\n\n## 9. Customers List and Detail\n\n### 9.1 Customers List\n\n**Route:** `GET /admin/customers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by name or email |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `customers` | Paginated, searched customers with `withCount('orders')` and `withSum('orders', 'total_amount')` |\n\n#### Layout\n\n- Heading \"Customers\"\n- Search input\n- Table columns: Name, Email, Orders count, Total spent (formatted), Created date\n- Each row links to customer detail\n\n### 9.2 Customer Detail\n\n**Route:** `GET /admin/customers/{customer}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Show`\n\n**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `customer` | `Customer` | The customer model |\n| `editingAddress` | `?CustomerAddress` | Address currently being edited |\n| `addressLabel` | `string` | Label for address form |\n| `addressJson` | `array` | Address fields being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Customer $customer)` | Loads customer with orders and addresses |\n| `openAddressForm(?CustomerAddress $address)` | Opens address modal for create/edit |\n| `saveAddress()` | Saves the address |\n| `deleteAddress(int $addressId)` | Deletes an address |\n| `setDefaultAddress(int $addressId)` | Sets address as default |\n\n#### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| CUSTOMER INFO                                | ADDRESSES          |\n| Name: Jane Smith                             | Home (default)     |\n| Email: jane@example.com                      | 123 Main St        |\n| Created: Jan 1, 2024                         | Springfield, IL    |\n| Marketing: [Opted In]                        | [Edit] [Delete]    |\n|                                              |                    |\n| ORDER HISTORY                                | Office             |\n| Order # | Date      | Status | Total         | 456 Corp Ave       |\n| #1005   | Jan 20    | Paid   | $89.00        | Chicago, IL        |\n| #1001   | Jan 15    | Paid   | $131.36       | [Edit] [Delete]    |\n| < 1 2 >                                     |                    |\n|                                              | [+ Add address]    |\n+----------------------------------------------+--------------------+\n```\n\n**Left column:**\n- Customer info card: Name, email, created date, marketing opt-in status (Flux badge)\n- Order history table: columns Order #, Date, Status, Total. Each order links to order detail. Paginated.\n\n**Right column:**\n- Addresses card: List of addresses, each showing label, full address text, default badge (if `is_default`), Edit and Delete buttons\n- \"Add address\" button opens address modal\n\n#### Address Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `address-form` |\n| Max width | md |\n| Heading | \"Edit address\" or \"Add address\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"Home, Office...\")\n- Address line 1 input","numLines":300,"startLine":900,"totalLines":2403}},"sourceToolAssistantUUID":"77822767-6ac7-4d9b-a8a8-a23a3243be89"}
{"parentUuid":"6239f8ad-d8e3-4c85-9d3d-b0413b2670bd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GYbYQgjqUyKEw4E3TVK9MM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WTCcriyUaH31BeYbQVTTs5","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","offset":1,"limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1698,"cache_read_input_tokens":47420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1698},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJe86eqBHZnmvMgNt7","type":"assistant","uuid":"a0fe8d72-13e1-46d3-b2f3-1937826b8b1b","timestamp":"2026-02-11T15:19:12.378Z"}
{"parentUuid":"a0fe8d72-13e1-46d3-b2f3-1937826b8b1b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01WTCcriyUaH31BeYbQVTTs5","toolUseID":"toolu_01WTCcriyUaH31BeYbQVTTs5","timestamp":"2026-02-11T15:19:12.380Z","uuid":"05c3e102-4823-48d8-902d-04746c5787d5"}
{"parentUuid":"a0fe8d72-13e1-46d3-b2f3-1937826b8b1b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WTCcriyUaH31BeYbQVTTs5","type":"tool_result","content":"     1→# 04 - Storefront UI/UX Specification\n     2→\n     3→> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n     4→\n     5→---\n     6→\n     7→## Conventions\n     8→\n     9→| Convention | Detail |\n    10→|---|---|\n    11→| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n    12→| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n    13→| Templating | Blade templates with reusable storefront-prefixed components |\n    14→| Accessibility | WCAG 2.2 AA compliance throughout |\n    15→| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n    16→| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n    17→| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n    18→\n    19→---\n    20→\n    21→## Currency Formatting\n    22→\n    23→All monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n    24→\n    25→| Setting | Value |\n    26→|---------|-------|\n    27→| Decimal separator | `.` (period) |\n    28→| Thousands separator | `,` (comma) |\n    29→| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n    30→| Decimal places | Always 2 |\n    31→| Free amounts | Display as `0.00 EUR` |\n    32→| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n    33→\n    34→**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n    35→\n    36→**Examples:**\n    37→- 2499 cents -> `24.99 EUR`\n    38→- 0 cents -> `0.00 EUR`\n    39→- 149900 cents -> `1,499.00 EUR`\n    40→\n    41→---\n    42→\n    43→## 1. Theme System Architecture\n    44→\n    45→### 1.1 View Organization\n    46→\n    47→The storefront views are organized into the following categories:\n    48→\n    49→| Category | Purpose | Views |\n    50→|---|---|---|\n    51→| Layouts | Master page shell | app (master layout) |\n    52→| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n    53→| Checkout | Purchase flow | checkout index, order confirmation |\n    54→| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n    55→| Errors | Error states | 404, 503 |\n    56→| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n    57→\n    58→### 1.2 Layout Hierarchy\n    59→\n    60→All storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n    61→\n    62→### 1.3 Theme Settings\n    63→\n    64→Theme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n    65→\n    66→- Announcement bar visibility, text, link, and background color\n    67→- Sticky header toggle\n    68→- Home page section order and visibility\n    69→- Color palette overrides (primary, secondary, accent)\n    70→- Typography scale\n    71→- Footer content and social links\n    72→- Dark mode behavior (system preference, toggle, or forced)\n    73→\n    74→### 1.4 Section-Based Architecture\n    75→\n    76→Each page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n    77→\n    78→| Section | Description | Used On |\n    79→|---|---|---|\n    80→| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n    81→| Featured Collections | Grid of collection cards | Home |\n    82→| Featured Products | Grid of product cards (lazy-loaded) | Home |\n    83→| Newsletter | Email signup form | Home, Footer |\n    84→| Rich Text | Customizable content block | Home, Pages |\n    85→\n    86→### 1.5 Blade Components\n    87→\n    88→All reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n    89→\n    90→---\n    91→\n    92→## 2. Base Layout\n    93→\n    94→### 2.1 Document Head\n    95→\n    96→The document head includes:\n    97→\n    98→| Element | Description |\n    99→|---|---|\n   100→| Character encoding | UTF-8 |"}]},"uuid":"fabf3967-e2f3-4142-bd7e-b948586ec9c8","timestamp":"2026-02-11T15:19:12.379Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"# 04 - Storefront UI/UX Specification\n\n> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n| Templating | Blade templates with reusable storefront-prefixed components |\n| Accessibility | WCAG 2.2 AA compliance throughout |\n| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n\n---\n\n## Currency Formatting\n\nAll monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n\n| Setting | Value |\n|---------|-------|\n| Decimal separator | `.` (period) |\n| Thousands separator | `,` (comma) |\n| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n| Decimal places | Always 2 |\n| Free amounts | Display as `0.00 EUR` |\n| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n\n**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n\n**Examples:**\n- 2499 cents -> `24.99 EUR`\n- 0 cents -> `0.00 EUR`\n- 149900 cents -> `1,499.00 EUR`\n\n---\n\n## 1. Theme System Architecture\n\n### 1.1 View Organization\n\nThe storefront views are organized into the following categories:\n\n| Category | Purpose | Views |\n|---|---|---|\n| Layouts | Master page shell | app (master layout) |\n| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n| Checkout | Purchase flow | checkout index, order confirmation |\n| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n| Errors | Error states | 404, 503 |\n| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n\n### 1.2 Layout Hierarchy\n\nAll storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n\n### 1.3 Theme Settings\n\nTheme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n\n- Announcement bar visibility, text, link, and background color\n- Sticky header toggle\n- Home page section order and visibility\n- Color palette overrides (primary, secondary, accent)\n- Typography scale\n- Footer content and social links\n- Dark mode behavior (system preference, toggle, or forced)\n\n### 1.4 Section-Based Architecture\n\nEach page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n\n| Section | Description | Used On |\n|---|---|---|\n| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n| Featured Collections | Grid of collection cards | Home |\n| Featured Products | Grid of product cards (lazy-loaded) | Home |\n| Newsletter | Email signup form | Home, Footer |\n| Rich Text | Customizable content block | Home, Pages |\n\n### 1.5 Blade Components\n\nAll reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n\n---\n\n## 2. Base Layout\n\n### 2.1 Document Head\n\nThe document head includes:\n\n| Element | Description |\n|---|---|\n| Character encoding | UTF-8 |","numLines":100,"startLine":1,"totalLines":1835}},"sourceToolAssistantUUID":"a0fe8d72-13e1-46d3-b2f3-1937826b8b1b"}
{"parentUuid":"fabf3967-e2f3-4142-bd7e-b948586ec9c8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GYbYQgjqUyKEw4E3TVK9MM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015vGxP83HTMeALThrxvdHnn","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","offset":900,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1698,"cache_read_input_tokens":47420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1698},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WJe86eqBHZnmvMgNt7","type":"assistant","uuid":"e56a0c3b-c746-4b55-9b1d-a5c99de84f5f","timestamp":"2026-02-11T15:19:13.120Z"}
{"parentUuid":"e56a0c3b-c746-4b55-9b1d-a5c99de84f5f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_015vGxP83HTMeALThrxvdHnn","toolUseID":"toolu_015vGxP83HTMeALThrxvdHnn","timestamp":"2026-02-11T15:19:13.124Z","uuid":"18f1bb4f-c65c-4dd2-9a1f-bf1a29df655a"}
{"parentUuid":"e56a0c3b-c746-4b55-9b1d-a5c99de84f5f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015vGxP83HTMeALThrxvdHnn","type":"tool_result","content":"   900→|  ------|------------- |---------|----------|---------|-------|\n   901→|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n   902→|        | Size: M      |         |          |         |       |\n   903→|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n   904→|        | Color: Blue  |         |          |         |       |\n   905→+--------------------------------------------------------------+\n   906→|                                            Subtotal: $84.97   |\n   907→|                              [Discount code] [Apply]          |\n   908→|                                            Discount: -$12.75  |\n   909→|                                            Total:    $72.22   |\n   910→|                                                               |\n   911→|                                            [Checkout button]  |\n   912→|                                            [Continue shopping]|\n   913→+--------------------------------------------------------------+\n   914→```\n   915→\n   916→| Attribute | Description |\n   917→|---|---|\n   918→| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n   919→| Title | H1, large bold, generous bottom margin |\n   920→| Desktop layout | Proper table with header row and body |\n   921→| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n   922→| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n   923→\n   924→### 7.2 Totals and Actions\n   925→\n   926→- Right-aligned on desktop, full-width on mobile\n   927→- Same discount code input/apply functionality as drawer\n   928→- Same totals breakdown\n   929→- \"Checkout\" button: primary, aligned right\n   930→- \"Continue shopping\" link: below checkout button\n   931→\n   932→### 7.3 Empty State\n   933→\n   934→Same as drawer empty state but centered in the page content area with more vertical padding.\n   935→\n   936→---\n   937→\n   938→## 8. Checkout Page (GET /checkout/{checkoutId})\n   939→\n   940→**Livewire Component:** CheckoutPage\n   941→\n   942→### 8.1 Layout\n   943→\n   944→**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n   945→\n   946→**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n   947→\n   948→```\n   949→Desktop:\n   950→+-------------------------------+-------------------+\n   951→|  [Checkout Form Sections]     | [Order Summary]   |\n   952→|  (wider, ~60%)                | (narrower, ~40%)  |\n   953→|                               | (sticky)          |\n   954→+-------------------------------+-------------------+\n   955→\n   956→Mobile:\n   957→+--------------------------------------------------------------+\n   958→|  [Order Summary - collapsible]                                |\n   959→+--------------------------------------------------------------+\n   960→|  [Checkout Form Sections]                                     |\n   961→+--------------------------------------------------------------+\n   962→```\n   963→\n   964→### 8.2 Checkout Steps\n   965→\n   966→The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.\n   967→\n   968→**Step State Behavior:**\n   969→\n   970→| State | Description |\n   971→|---|---|\n   972→| Completed | Shows summary of entered data with an \"Edit\" link |\n   973→| Current | Expanded with the full form |\n   974→| Future | Collapsed, muted, not interactive |\n   975→\n   976→Each step header shows the step number + title, e.g., \"1. Contact information\".\n   977→\n   978→#### Step 1 - Contact Information\n   979→\n   980→| Field | Description |\n   981→|---|---|\n   982→| Email | Full-width email input, required. Validated on blur |\n   983→| Account link | \"Already have an account? Log in\" link below email, small blue text |\n   984→| Continue button | Primary, below the form |\n   985→\n   986→#### Step 2 - Shipping Address\n   987→\n   988→Uses the address-form component.\n   989→\n   990→**Fields:**\n   991→\n   992→| Field | Type | Required | Layout Width |\n   993→|---|---|---|---|\n   994→| First name | Text | Yes | Half |\n   995→| Last name | Text | Yes | Half |\n   996→| Address line 1 | Text | Yes | Full |\n   997→| Address line 2 | Text | No | Full |\n   998→| City | Text | Yes | Half |\n   999→| State/Province | Select or text | Yes (varies by country) | Half |\n  1000→| Postal code | Text | Yes | Half |\n  1001→| Country | Select | Yes | Half |\n  1002→| Phone | Tel | No | Full |\n  1003→\n  1004→- Layout: 2-column grid, full-width fields span both columns\n  1005→- Country select: on change, State/Province updates to show relevant regions\n  1006→- All fields validate on blur\n  1007→\n  1008→**Billing address:**\n  1009→\n  1010→- Checkbox: \"Billing address same as shipping\" (checked by default)\n  1011→- When unchecked, a second address form appears for billing\n  1012→\n  1013→**Saved addresses (logged-in customers):**\n  1014→\n  1015→- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n  1016→- \"Use a new address\" option to show the blank form\n  1017→- On selection, form fields populate with the saved address data\n  1018→\n  1019→Continue button: primary.\n  1020→\n  1021→#### Step 3 - Shipping Method\n  1022→\n  1023→Radio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n  1024→\n  1025→```\n  1026→+--------------------------------------------------------------+\n  1027→| (o) Standard Shipping          5-7 business days       $7.99 |\n  1028→| ( ) Express Shipping           2-3 business days      $14.99 |\n  1029→| ( ) Free Shipping              7-10 business days      Free  |\n  1030→+--------------------------------------------------------------+\n  1031→```\n  1032→\n  1033→| Attribute | Description |\n  1034→|---|---|\n  1035→| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n  1036→| Label layout | Method name (left), estimated delivery (center), price (right) |\n  1037→| Live binding | On selection, the order summary sidebar updates totals |\n  1038→| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n  1039→\n  1040→Continue button: primary.\n  1041→\n  1042→#### Step 4 - Payment\n  1043→\n  1044→| Element | Description |\n  1045→|---|---|\n  1046→| Order review | Brief summary of items, address, and shipping method |\n  1047→| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n  1048→| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n  1049→| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n  1050→\n  1051→### 8.3 Order Summary Sidebar (Right Column)\n  1052→\n  1053→Sticky on desktop.\n  1054→\n  1055→```\n  1056→+-------------------------------+\n  1057→|  Order Summary                |\n  1058→+-------------------------------+\n  1059→|  [Thumb] Title x2     $49.98 |\n  1060→|          Size: M              |\n  1061→|  [Thumb] Title x1     $34.99 |\n  1062→+-------------------------------+\n  1063→|  [Discount code] [Apply]     |\n  1064→+-------------------------------+\n  1065→|  Subtotal              $84.97|\n  1066→|  Discount             -$12.75|\n  1067→|  Shipping               $7.99|\n  1068→|  Tax                    $5.74|\n  1069→|  ----------------------------|\n  1070→|  Total                 $85.95|\n  1071→+-------------------------------+\n  1072→```\n  1073→\n  1074→| Element | Description |\n  1075→|---|---|\n  1076→| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n  1077→| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n  1078→| Discount code input | Same as cart drawer |\n  1079→| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n  1080→| Updates | Event-driven from step completion actions (no polling) |\n  1081→\n  1082→### 8.4 Accessibility\n  1083→\n  1084→| Requirement | Description |\n  1085→|---|---|\n  1086→| Labels | All form inputs have associated label elements |\n  1087→| Validation errors | Linked via describedby pointing to error message element |\n  1088→| Required fields | Marked with required attribute and visual asterisk |\n  1089→| Step transitions | Focus moves to the first input of the newly expanded step |\n  1090→| Shipping radios | Grouped in a fieldset with legend |\n  1091→| Totals | Live region for screen reader updates |\n  1092→\n  1093→---\n  1094→\n  1095→## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n  1096→\n  1097→### 9.1 Layout\n  1098→\n  1099→Centered content, single column.\n  1100→\n  1101→```\n  1102→+--------------------------------------------------------------+\n  1103→|  [Checkmark icon - green circle]                              |\n  1104→|  \"Thank you for your order!\"                                  |\n  1105→|  Order #1042                                                  |\n  1106→|  \"We've sent a confirmation to you@email.com\"                 |\n  1107→+--------------------------------------------------------------+\n  1108→|  Order Summary                                                |\n  1109→|  [Items list with thumbnails]                                 |\n  1110→+--------------------------------------------------------------+\n  1111→|  Shipping Address          |  Payment Method                  |\n  1112→|  Jane Doe                  |  Visa ending in 4242             |\n  1113→|  123 Main St               |                                  |\n  1114→|  New York, NY 10001        |                                  |\n  1115→+--------------------------------------------------------------+\n  1116→|  Subtotal        $84.97                                       |\n  1117→|  Shipping         $7.99                                       |\n  1118→|  Tax              $5.74                                       |\n  1119→|  Total           $98.70                                       |\n  1120→+--------------------------------------------------------------+\n  1121→|  [Continue shopping button]  [View order - if logged in]      |\n  1122→+--------------------------------------------------------------+\n  1123→```\n  1124→\n  1125→| Element | Description |\n  1126→|---|---|\n  1127→| Container | Constrained to a comfortable width, centered, generous vertical padding |\n  1128→| Success icon | Large green checkmark in a circle, centered |\n  1129→| Heading | H1, large bold, centered, generous top margin |\n  1130→| Order number | Medium muted text, centered |\n  1131→| Email notice | Small muted text, centered |\n  1132→\n  1133→### 9.2 Order Details\n  1134→\n  1135→| Element | Description |\n  1136→|---|---|\n  1137→| Items | List with thumbnail, title, variant info, quantity, price |\n  1138→| Address and payment | Two-column grid on desktop, stacked on mobile |\n  1139→| Totals | Right-aligned summary |\n  1140→\n  1141→### 9.3 Actions\n  1142→\n  1143→| Button | Description |\n  1144→|---|---|\n  1145→| Continue shopping | Primary, links to home page |\n  1146→| View order | Link to account order detail page (logged-in customers only) |\n  1147→| Layout | Centered row with gap |\n  1148→\n  1149→---\n  1150→\n  1151→## 10. Customer Account Pages\n  1152→\n  1153→All account pages require authentication. Unauthenticated access redirects to the login page.\n  1154→\n  1155→### 10.1 Login (GET /account/login)\n  1156→\n  1157→**Livewire Component:** Login\n  1158→\n  1159→```\n  1160→+--------------------------------------------------------------+\n  1161→|  Log in to your account                                       |\n  1162→|                                                               |\n  1163→|  [Email input]                                                |\n  1164→|  [Password input]                                             |\n  1165→|  [Forgot password? link]                                      |\n  1166→|  [Log in button]                                              |\n  1167→|                                                               |\n  1168→|  Don't have an account? [Create one]                          |\n  1169→+--------------------------------------------------------------+\n  1170→```\n  1171→\n  1172→| Element | Description |\n  1173→|---|---|\n  1174→| Container | Narrow max-width, centered, generous vertical padding |\n  1175→| Heading | H1, medium-large, bold, centered |\n  1176→| Form | Vertical stack with gap |\n  1177→| Email | Email type, required, autocomplete for email |\n  1178→| Password | Password type, required, autocomplete for current password |\n  1179→| Forgot password | Small blue link, right-aligned |\n  1180→| Submit button | Primary, full-width, \"Log in\" |\n  1181→| Error messages | Inline below fields in small red text |\n  1182→| General error | Invalid credentials shown in a callout/alert above the form |\n  1183→| Register link | \"Create one\" link to registration page |\n  1184→\n  1185→### 10.2 Register (GET /account/register)\n  1186→\n  1187→**Livewire Component:** Register\n  1188→\n  1189→```\n  1190→+--------------------------------------------------------------+\n  1191→|  Create an account                                            |\n  1192→|                                                               |\n  1193→|  [Name input]                                                 |\n  1194→|  [Email input]                                                |\n  1195→|  [Password input]                                             |\n  1196→|  [Confirm password input]                                     |\n  1197→|  [ ] Subscribe to marketing emails                            |\n  1198→|  [Create account button]                                      |\n  1199→|                                                               |"}]},"uuid":"46f5ec04-d608-4e36-b5c5-b4f316ff3fc1","timestamp":"2026-02-11T15:19:13.123Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"|  ------|------------- |---------|----------|---------|-------|\n|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n|        | Size: M      |         |          |         |       |\n|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n|        | Color: Blue  |         |          |         |       |\n+--------------------------------------------------------------+\n|                                            Subtotal: $84.97   |\n|                              [Discount code] [Apply]          |\n|                                            Discount: -$12.75  |\n|                                            Total:    $72.22   |\n|                                                               |\n|                                            [Checkout button]  |\n|                                            [Continue shopping]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n| Title | H1, large bold, generous bottom margin |\n| Desktop layout | Proper table with header row and body |\n| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n\n### 7.2 Totals and Actions\n\n- Right-aligned on desktop, full-width on mobile\n- Same discount code input/apply functionality as drawer\n- Same totals breakdown\n- \"Checkout\" button: primary, aligned right\n- \"Continue shopping\" link: below checkout button\n\n### 7.3 Empty State\n\nSame as drawer empty state but centered in the page content area with more vertical padding.\n\n---\n\n## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment\n\n| Element | Description |\n|---|---|\n| Order review | Brief summary of items, address, and shipping method |\n| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.\n\n```\n+--------------------------------------------------------------+\n|  [Checkmark icon - green circle]                              |\n|  \"Thank you for your order!\"                                  |\n|  Order #1042                                                  |\n|  \"We've sent a confirmation to you@email.com\"                 |\n+--------------------------------------------------------------+\n|  Order Summary                                                |\n|  [Items list with thumbnails]                                 |\n+--------------------------------------------------------------+\n|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Visa ending in 4242             |\n|  123 Main St               |                                  |\n|  New York, NY 10001        |                                  |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  [Continue shopping button]  [View order - if logged in]      |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained to a comfortable width, centered, generous vertical padding |\n| Success icon | Large green checkmark in a circle, centered |\n| Heading | H1, large bold, centered, generous top margin |\n| Order number | Medium muted text, centered |\n| Email notice | Small muted text, centered |\n\n### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Totals | Right-aligned summary |\n\n### 9.3 Actions\n\n| Button | Description |\n|---|---|\n| Continue shopping | Primary, links to home page |\n| View order | Link to account order detail page (logged-in customers only) |\n| Layout | Centered row with gap |\n\n---\n\n## 10. Customer Account Pages\n\nAll account pages require authentication. Unauthenticated access redirects to the login page.\n\n### 10.1 Login (GET /account/login)\n\n**Livewire Component:** Login\n\n```\n+--------------------------------------------------------------+\n|  Log in to your account                                       |\n|                                                               |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Forgot password? link]                                      |\n|  [Log in button]                                              |\n|                                                               |\n|  Don't have an account? [Create one]                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, autocomplete for current password |\n| Forgot password | Small blue link, right-aligned |\n| Submit button | Primary, full-width, \"Log in\" |\n| Error messages | Inline below fields in small red text |\n| General error | Invalid credentials shown in a callout/alert above the form |\n| Register link | \"Create one\" link to registration page |\n\n### 10.2 Register (GET /account/register)\n\n**Livewire Component:** Register\n\n```\n+--------------------------------------------------------------+\n|  Create an account                                            |\n|                                                               |\n|  [Name input]                                                 |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Confirm password input]                                     |\n|  [ ] Subscribe to marketing emails                            |\n|  [Create account button]                                      |\n|                                                               |","numLines":300,"startLine":900,"totalLines":1835}},"sourceToolAssistantUUID":"e56a0c3b-c746-4b55-9b1d-a5c99de84f5f"}
{"parentUuid":"46f5ec04-d608-4e36-b5c5-b4f316ff3fc1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a good understanding of what needs to be edited. Let me also read the remaining files I need to modify. Let me also check for Stripe/PayPal references in the other spec files and read the sections I need to update.\n\nI have the 02-API-ROUTES.md file with remaining Stripe/PayPal references in:\n- Section 4 (Webhook Receiver Endpoints) at lines 2850-2967 - needs to be removed/replaced\n- Line 1946 - provider: \"stripe\" reference \n- Lines 2422-2433 - stripe_tax references (these might be legitimate tax provider references)\n","signature":"EtYHCkYICxgCKkCwgXfAGE8OgCC7O8G0H9tUz2qtYBblduOragjXdBU4ZagsYO59x7t36VCgVeRhaFXHhJi3hTT1Sa2IiBf+14MiEgzRZj+DcvfF4y4lMn4aDF0GpmeuESkJx3kYKCIwcT8q7GVOiZWB0EIFEE2weOr1BnZSG5UiAwAxtzDMtVuvOBXk+Gagf2Pt6AMsofkuKr0GW+ktqoTiP6emYMQyU4zZLasg9ZSiNfn8m2xR9bEuFRY/I8lxfsmEWaZYbj2twou6R5BAkDY0hYavqq0JrfUB+fVbZ09Aky0m48z/vqJn9Aa4Z8KdICnHPd6MbZOPAQ7vI9oFehLRwb2+BQwKA7B8Yj6hc/Be9Kpg0/DsLT4LMrthxNxJwficS+a0csNfmcPyyB9M8L5zSWjaRB5JBwC7epblw/KlUjvEdkASjQ0Ll3Bo5n4prGINoGLArOPT1s33zBzgxY/Lnxaa3e2QcIh26ZQdVFN+EBV8nNABqIK0WGQM9m0YyyrzBHZosMgEFyg9+MB5nDbc/ss7oZJBennFx3cd4x5h3WwhmXGElpkdOeHSyaLVq/SJjrvqzNCDlGDM34S5Oeu1cN3XIzQ6hM1qaABvwKva8Y4ge7HNonZfStyzpo2B3zW81nbYduTT8mum3j/vCTgrMBuBJPuwCc5WrT1MHQhh1Xpqt6d2RrxgL3A1ShU2djP9RN9zdE65e+15uRKHiIu4I7l7GRU8oTkI6kcWj4rHcSqxKUZO00H7mRBafiJuWBPOgbd0Iqbed7haREChTIaIsHOVWnQWABgysE/ug+/nk+PmQgMPOdu4j/GCp9g9Uu6PVrbNWu+iq3WBsIh6cOR1COe4Z5iXuWKYMhWeWk8AqN51rE2tyLS4te8jEVvMgmQLnJU5qps6hTyfc8gepcmNMHo7ktnf0982vX2+laBq/bXBl/Z7k9U6zoTJgvL5urLK9hZ+z8OdeJIJRT1nzQK3zZIsytFnM9KGEbfM75UUz78hsCXrBqAGrQwANur3cA+bIdK42e2Kf07Z3a+uvcv5p96yp4U0BQTwI19ozsQz/GGLQOnZ+qTTGVFQzBQuWioAUfsWhcf1Kq4Dul9nJuPLTpLv/S4VofzrCBrDh3WmwA8rhzO3YEYWJJi84wDWBae+pPQa7p/ltKEujK7+myhl1iPVn/HED3UE+EyDz5B1O9CrgRG1KkhwNPHYhJy+mW36Z77bGi41woD6KBTIS7B8c64bf/O0sT3xLT1mcgj/26l1HOyhWMxd+GpjqwuQLFn4Hwwmjtiv0YVZJI0zt0luAAunRDb2pRgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"51f54d1c-992e-4566-b5a9-f4c33921975e","timestamp":"2026-02-11T15:19:34.798Z"}
{"parentUuid":"51f54d1c-992e-4566-b5a9-f4c33921975e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the remaining sections I need to edit across all files."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"fd4a999c-b8cf-4161-9197-aee572fe4c16","timestamp":"2026-02-11T15:19:34.806Z"}
{"parentUuid":"fd4a999c-b8cf-4161-9197-aee572fe4c16","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0165JtaL99DW5SygjXMHuiUM","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":1920,"limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"fee29b25-3c28-4d95-9099-a1fd5959936d","timestamp":"2026-02-11T15:19:34.879Z"}
{"parentUuid":"fee29b25-3c28-4d95-9099-a1fd5959936d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_0165JtaL99DW5SygjXMHuiUM","toolUseID":"toolu_0165JtaL99DW5SygjXMHuiUM","timestamp":"2026-02-11T15:19:34.884Z","uuid":"01eef658-8ebd-4558-ab12-c4192fcbcdef"}
{"parentUuid":"fee29b25-3c28-4d95-9099-a1fd5959936d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0165JtaL99DW5SygjXMHuiUM","type":"tool_result","content":"  1920→      \"address1\": \"123 Main St\",\n  1921→      \"city\": \"Berlin\",\n  1922→      \"country_code\": \"DE\",\n  1923→      \"postal_code\": \"10115\"\n  1924→    },\n  1925→    \"lines\": [\n  1926→      {\n  1927→        \"id\": 301,\n  1928→        \"product_id\": 10,\n  1929→        \"variant_id\": 101,\n  1930→        \"title_snapshot\": \"Classic T-Shirt\",\n  1931→        \"sku_snapshot\": \"TSH-BLU-M\",\n  1932→        \"quantity\": 2,\n  1933→        \"unit_price_amount\": 2500,\n  1934→        \"total_amount\": 5000,\n  1935→        \"tax_lines_json\": [\n  1936→          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n  1937→        ],\n  1938→        \"discount_allocations_json\": [\n  1939→          { \"code\": \"WELCOME10\", \"amount\": 500 }\n  1940→        ]\n  1941→      }\n  1942→    ],\n  1943→    \"payments\": [\n  1944→      {\n  1945→        \"id\": 401,\n  1946→        \"provider\": \"stripe\",\n  1947→        \"provider_payment_id\": \"pi_abc123\",\n  1948→        \"status\": \"captured\",\n  1949→        \"amount\": 5950,\n  1950→        \"currency\": \"EUR\",\n  1951→        \"created_at\": \"2026-02-11T10:10:00Z\"\n  1952→      }\n  1953→    ],\n  1954→    \"fulfillments\": [],\n  1955→    \"refunds\": [],\n  1956→    \"placed_at\": \"2026-02-11T10:10:00Z\",\n  1957→    \"created_at\": \"2026-02-11T10:10:00Z\",\n  1958→    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  1959→  }\n  1960→}\n  1961→```\n  1962→\n  1963→**Error responses:**\n  1964→- `403` - Insufficient permissions\n  1965→- `404` - Order not found\n  1966→\n  1967→---\n  1968→\n  1969→#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n  1970→\n  1971→Create a fulfillment (mark items as shipped).\n  1972→\n  1973→**Request body:**\n  1974→\n  1975→```json\n  1976→{\n  1977→  \"tracking_company\": \"DHL\",\n  1978→  \"tracking_number\": \"1234567890\",\n  1979→  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  1980→  \"line_items\": [\n  1981→    {\n  1982→      \"order_line_id\": 301,\n  1983→      \"quantity\": 2\n  1984→    }\n  1985→  ],\n  1986→  \"notify_customer\": true\n  1987→}\n  1988→```\n  1989→\n  1990→| Field | Type | Required | Validation |\n  1991→|-------|------|----------|------------|\n  1992→| `tracking_company` | string | No | Max: 255 |\n  1993→| `tracking_number` | string | No | Max: 255 |\n  1994→| `tracking_url` | string | No | Valid URL, max: 2048 |\n  1995→| `line_items` | array | Yes | At least one line item. |\n  1996→| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n  1997→| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n  1998→| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n  1999→\n  2000→**Success response:** `201 Created`\n  2001→\n  2002→```json\n  2003→{\n  2004→  \"data\": {\n  2005→    \"id\": 501,\n  2006→    \"order_id\": 50,\n  2007→    \"status\": \"shipped\",\n  2008→    \"tracking_company\": \"DHL\",\n  2009→    \"tracking_number\": \"1234567890\",\n  2010→    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  2011→    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n  2012→    \"line_items\": [\n  2013→      {\n  2014→        \"order_line_id\": 301,\n  2015→        \"quantity\": 2\n  2016→      }\n  2017→    ]\n  2018→  }\n  2019→}"}]},"uuid":"ce1525f0-b181-41e6-8e0e-e9934a9482b6","timestamp":"2026-02-11T15:19:34.883Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"stripe\",\n        \"provider_payment_id\": \"pi_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}","numLines":100,"startLine":1920,"totalLines":3336}},"sourceToolAssistantUUID":"fee29b25-3c28-4d95-9099-a1fd5959936d"}
{"parentUuid":"ce1525f0-b181-41e6-8e0e-e9934a9482b6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AivphaJZm8cB5aLRYf8u5V","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":2400,"limit":50}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"39636562-b449-455e-9c61-dd024e46bd43","timestamp":"2026-02-11T15:19:34.885Z"}
{"parentUuid":"39636562-b449-455e-9c61-dd024e46bd43","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01AivphaJZm8cB5aLRYf8u5V","toolUseID":"toolu_01AivphaJZm8cB5aLRYf8u5V","timestamp":"2026-02-11T15:19:34.887Z","uuid":"7f88da1f-f36c-4bcc-86be-ef973cfc5315"}
{"parentUuid":"39636562-b449-455e-9c61-dd024e46bd43","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AivphaJZm8cB5aLRYf8u5V","type":"tool_result","content":"  2400→          \"rate\": 2000,\n  2401→          \"name\": \"USt\",\n  2402→          \"shipping_taxed\": true\n  2403→        }\n  2404→      ]\n  2405→    },\n  2406→    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  2407→  }\n  2408→}\n  2409→```\n  2410→\n  2411→---\n  2412→\n  2413→#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n  2414→\n  2415→Update tax configuration.\n  2416→\n  2417→**Request body:**\n  2418→\n  2419→```json\n  2420→{\n  2421→  \"mode\": \"provider\",\n  2422→  \"provider\": \"stripe_tax\",\n  2423→  \"prices_include_tax\": false,\n  2424→  \"config_json\": {\n  2425→    \"stripe_tax_settings_id\": \"txr_abc123\"\n  2426→  }\n  2427→}\n  2428→```\n  2429→\n  2430→| Field | Type | Required | Validation |\n  2431→|-------|------|----------|------------|\n  2432→| `mode` | string | Yes | One of: `manual`, `provider`. |\n  2433→| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n  2434→| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n  2435→| `config_json` | object | Yes | Schema depends on mode/provider. |\n  2436→\n  2437→**Success response:** `200 OK`\n  2438→\n  2439→**Error responses:**\n  2440→- `403` - Insufficient permissions\n  2441→- `422` - Validation errors\n  2442→\n  2443→---\n  2444→\n  2445→### 3.8 Themes\n  2446→\n  2447→#### Admin Themes Endpoint Summary\n  2448→\n  2449→| Method | Path | Auth Scope | Description | Rate Limit |"}]},"uuid":"0bf66c81-5bc8-476d-91ad-2b7c0a7121b5","timestamp":"2026-02-11T15:19:34.887Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |","numLines":50,"startLine":2400,"totalLines":3336}},"sourceToolAssistantUUID":"39636562-b449-455e-9c61-dd024e46bd43"}
{"parentUuid":"0bf66c81-5bc8-476d-91ad-2b7c0a7121b5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DdkLRTGmS9uqG9aR27eGye","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":2830,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"a59f2339-80a4-4a55-9d17-3a2002966bdb","timestamp":"2026-02-11T15:19:34.939Z"}
{"parentUuid":"a59f2339-80a4-4a55-9d17-3a2002966bdb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01DdkLRTGmS9uqG9aR27eGye","toolUseID":"toolu_01DdkLRTGmS9uqG9aR27eGye","timestamp":"2026-02-11T15:19:34.941Z","uuid":"645da497-5270-4b12-8364-673ae3b1542d"}
{"parentUuid":"a59f2339-80a4-4a55-9d17-3a2002966bdb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DdkLRTGmS9uqG9aR27eGye","type":"tool_result","content":"  2830→    \"id\": 1,\n  2831→    \"status\": \"completed\",\n  2832→    \"format\": \"csv\",\n  2833→    \"row_count\": 156,\n  2834→    \"download_url\": \"https://s3.example.com/...presigned...\",\n  2835→    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n  2836→    \"created_at\": \"2026-02-11T10:00:00Z\",\n  2837→    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  2838→  }\n  2839→}\n  2840→```\n  2841→\n  2842→**Export status values:** `queued`, `processing`, `completed`, `failed`.\n  2843→\n  2844→**Error responses:**\n  2845→- `403` - Insufficient permissions\n  2846→- `404` - Export not found\n  2847→\n  2848→---\n  2849→\n  2850→## 4. Webhook Receiver Endpoints\n  2851→\n  2852→These endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n  2853→\n  2854→**Prefix:** `/api/webhooks`\n  2855→**Middleware:** None (raw request body needed for signature verification)\n  2856→\n  2857→---\n  2858→\n  2859→### POST /api/webhooks/stripe\n  2860→\n  2861→Receive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n  2862→\n  2863→- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n  2864→- **Rate limit:** `webhooks` (100/min per IP)\n  2865→\n  2866→**Required headers:**\n  2867→\n  2868→| Header | Required | Description |\n  2869→|--------|----------|-------------|\n  2870→| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n  2871→| `Content-Type` | Yes | `application/json` |\n  2872→\n  2873→**Example event payload:**\n  2874→\n  2875→```json\n  2876→{\n  2877→  \"id\": \"evt_abc123\",\n  2878→  \"type\": \"payment_intent.succeeded\",\n  2879→  \"data\": {\n  2880→    \"object\": {\n  2881→      \"id\": \"pi_abc123\",\n  2882→      \"amount\": 6545,\n  2883→      \"currency\": \"eur\",\n  2884→      \"status\": \"succeeded\",\n  2885→      \"metadata\": {\n  2886→        \"checkout_id\": 7,\n  2887→        \"store_id\": 1\n  2888→      }\n  2889→    }\n  2890→  },\n  2891→  \"created\": 1707645000\n  2892→}\n  2893→```\n  2894→\n  2895→**Webhook processing steps:**\n  2896→\n  2897→1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n  2898→2. Track idempotency via `event.id` to prevent duplicate processing\n  2899→3. Dispatch appropriate action based on event type\n  2900→\n  2901→**Supported Stripe event types and actions:**\n  2902→\n  2903→| Event Type | Action |\n  2904→|------------|--------|\n  2905→| `payment_intent.succeeded` | Transition order to `paid`, decrement inventory |\n  2906→| `payment_intent.payment_failed` | Mark payment as failed, notify customer |\n  2907→| `charge.refunded` | Process refund, update order financial_status |\n  2908→| `checkout.session.completed` | Reconcile Checkout Session flow |\n  2909→\n  2910→**Success response:** `200 OK`\n  2911→\n  2912→```json\n  2913→{\n  2914→  \"received\": true\n  2915→}\n  2916→```\n  2917→\n  2918→**Error responses:**\n  2919→- `400` - Invalid signature or malformed payload\n  2920→\n  2921→---\n  2922→\n  2923→### POST /api/webhooks/paypal\n  2924→\n  2925→Receive PayPal webhook notifications.\n  2926→\n  2927→- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n  2928→- **Rate limit:** `webhooks` (100/min per IP)\n  2929→\n  2930→**Required headers:**\n  2931→\n  2932→| Header | Required | Description |\n  2933→|--------|----------|-------------|\n  2934→| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n  2935→| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n  2936→| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n  2937→| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n  2938→| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n  2939→| `Content-Type` | Yes | `application/json` |\n  2940→\n  2941→**Example event payload:**\n  2942→\n  2943→```json\n  2944→{\n  2945→  \"id\": \"WH-abc123\",\n  2946→  \"event_type\": \"PAYMENT.CAPTURE.COMPLETED\",\n  2947→  \"resource\": {\n  2948→    \"id\": \"5O190127TN364715T\",\n  2949→    \"status\": \"COMPLETED\",\n  2950→    \"amount\": {\n  2951→      \"currency_code\": \"EUR\",\n  2952→      \"value\": \"65.45\"\n  2953→    },\n  2954→    \"custom_id\": 7\n  2955→  },\n  2956→  \"create_time\": \"2026-02-11T10:10:00Z\"\n  2957→}\n  2958→```\n  2959→\n  2960→**Webhook processing steps:**\n  2961→\n  2962→1. Verify signature by calling PayPal's verification endpoint\n  2963→2. Check `verification_status` is `SUCCESS`; reject if `FAILURE`\n  2964→3. Track idempotency via `event.id`\n  2965→4. Dispatch action based on `event_type`\n  2966→\n  2967→**Supported PayPal event types and actions:**\n  2968→\n  2969→| Event Type | Action |\n  2970→|------------|--------|\n  2971→| `PAYMENT.CAPTURE.COMPLETED` | Transition order to `paid`, decrement inventory |\n  2972→| `PAYMENT.CAPTURE.DENIED` | Mark payment as failed |\n  2973→| `PAYMENT.CAPTURE.REFUNDED` | Process refund |\n  2974→\n  2975→**Success response:** `200 OK`\n  2976→\n  2977→```json\n  2978→{\n  2979→  \"received\": true\n  2980→}\n  2981→```\n  2982→\n  2983→**Error responses:**\n  2984→- `400` - Verification failed or malformed payload\n  2985→\n  2986→---\n  2987→\n  2988→## 5. OAuth Endpoints (App Ecosystem)\n  2989→\n  2990→> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n  2991→\n  2992→These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n  2993→\n  2994→---\n  2995→\n  2996→### GET /oauth/authorize\n  2997→\n  2998→Display the authorization prompt to the merchant.\n  2999→\n  3000→- **Middleware:** `web`, `auth`\n  3001→\n  3002→**Query parameters:**\n  3003→\n  3004→| Parameter | Type | Required | Description |\n  3005→|-----------|------|----------|-------------|\n  3006→| `client_id` | string | Yes | The app's OAuth client ID |\n  3007→| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n  3008→| `response_type` | string | Yes | Must be `code` |\n  3009→| `scope` | string | Yes | Space-separated list of requested scopes |\n  3010→| `state` | string | Yes | CSRF token for the client |\n  3011→\n  3012→**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n  3013→\n  3014→---\n  3015→\n  3016→### POST /oauth/token\n  3017→\n  3018→Exchange an authorization code for access and refresh tokens.\n  3019→\n  3020→- **Middleware:** None (public endpoint)\n  3021→\n  3022→**Authorization code exchange request:**\n  3023→\n  3024→```json\n  3025→{\n  3026→  \"grant_type\": \"authorization_code\",\n  3027→  \"client_id\": \"app_client_abc123\",\n  3028→  \"client_secret\": \"secret_xyz\",\n  3029→  \"redirect_uri\": \"https://myapp.example.com/callback\","}]},"uuid":"7cce9c14-971e-4d0f-b065-755e2f118e51","timestamp":"2026-02-11T15:19:34.940Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\nThese endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n\n**Prefix:** `/api/webhooks`\n**Middleware:** None (raw request body needed for signature verification)\n\n---\n\n### POST /api/webhooks/stripe\n\nReceive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n\n- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"evt_abc123\",\n  \"type\": \"payment_intent.succeeded\",\n  \"data\": {\n    \"object\": {\n      \"id\": \"pi_abc123\",\n      \"amount\": 6545,\n      \"currency\": \"eur\",\n      \"status\": \"succeeded\",\n      \"metadata\": {\n        \"checkout_id\": 7,\n        \"store_id\": 1\n      }\n    }\n  },\n  \"created\": 1707645000\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n2. Track idempotency via `event.id` to prevent duplicate processing\n3. Dispatch appropriate action based on event type\n\n**Supported Stripe event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `payment_intent.succeeded` | Transition order to `paid`, decrement inventory |\n| `payment_intent.payment_failed` | Mark payment as failed, notify customer |\n| `charge.refunded` | Process refund, update order financial_status |\n| `checkout.session.completed` | Reconcile Checkout Session flow |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Invalid signature or malformed payload\n\n---\n\n### POST /api/webhooks/paypal\n\nReceive PayPal webhook notifications.\n\n- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"WH-abc123\",\n  \"event_type\": \"PAYMENT.CAPTURE.COMPLETED\",\n  \"resource\": {\n    \"id\": \"5O190127TN364715T\",\n    \"status\": \"COMPLETED\",\n    \"amount\": {\n      \"currency_code\": \"EUR\",\n      \"value\": \"65.45\"\n    },\n    \"custom_id\": 7\n  },\n  \"create_time\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify signature by calling PayPal's verification endpoint\n2. Check `verification_status` is `SUCCESS`; reject if `FAILURE`\n3. Track idempotency via `event.id`\n4. Dispatch action based on `event_type`\n\n**Supported PayPal event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `PAYMENT.CAPTURE.COMPLETED` | Transition order to `paid`, decrement inventory |\n| `PAYMENT.CAPTURE.DENIED` | Mark payment as failed |\n| `PAYMENT.CAPTURE.REFUNDED` | Process refund |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Verification failed or malformed payload\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",","numLines":200,"startLine":2830,"totalLines":3336}},"sourceToolAssistantUUID":"a59f2339-80a4-4a55-9d17-3a2002966bdb"}
{"parentUuid":"7cce9c14-971e-4d0f-b065-755e2f118e51","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SWQ2DCHs9n5HH3wysHoCcp","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":3100,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"44915f1b-3d24-486c-ba7a-3c7be4da68a2","timestamp":"2026-02-11T15:19:34.958Z"}
{"parentUuid":"44915f1b-3d24-486c-ba7a-3c7be4da68a2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01SWQ2DCHs9n5HH3wysHoCcp","toolUseID":"toolu_01SWQ2DCHs9n5HH3wysHoCcp","timestamp":"2026-02-11T15:19:34.960Z","uuid":"d81a6420-717f-4cf7-8894-c09f46fc95ba"}
{"parentUuid":"44915f1b-3d24-486c-ba7a-3c7be4da68a2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SWQ2DCHs9n5HH3wysHoCcp","type":"tool_result","content":"  3100→| `verified` | Require verified email address |\n  3101→| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n  3102→| `role.check` | Verify user has required role for the current store |\n  3103→\n  3104→#### Storefront\n  3105→\n  3106→Applied to storefront routes in addition to `web`:\n  3107→\n  3108→| Middleware | Purpose |\n  3109→|-----------|---------|\n  3110→| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n  3111→\n  3112→#### API Storefront\n  3113→\n  3114→Applied to `/api/storefront/v1/*` routes:\n  3115→\n  3116→| Middleware | Purpose |\n  3117→|-----------|---------|\n  3118→| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n  3119→| `store.resolve` | Resolve store from request hostname |\n  3120→\n  3121→#### API Admin\n  3122→\n  3123→Applied to `/api/admin/v1/*` routes:\n  3124→\n  3125→| Middleware | Purpose |\n  3126→|-----------|---------|\n  3127→| `auth:sanctum` | Sanctum token authentication |\n  3128→| `store.resolve` | Resolve store from route parameter or token context |\n  3129→| `throttle:api.admin` | Rate limiting (60/min per token) |\n  3130→\n  3131→#### Webhooks\n  3132→\n  3133→Applied to `/api/webhooks/*` routes:\n  3134→\n  3135→| Middleware | Purpose |\n  3136→|-----------|---------|\n  3137→| `throttle:webhooks` | Rate limiting (100/min per IP) |\n  3138→\n  3139→No session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.\n  3140→\n  3141→---\n  3142→\n  3143→### 6.2 Custom Middleware Definitions\n  3144→\n  3145→#### `store.resolve`\n  3146→\n  3147→Resolves the current store context. The resolution strategy depends on the route group:\n  3148→\n  3149→- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n  3150→- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n  3151→- **API (storefront):** Same as storefront web - hostname-based resolution.\n  3152→- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n  3153→\n  3154→Sets `app.store_id` in the service container and binds the `Store` model to the request.\n  3155→\n  3156→**Failure responses:**\n  3157→- `404` if store cannot be resolved from hostname.\n  3158→- `403` if user does not have access to the requested store.\n  3159→- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n  3160→\n  3161→#### `role.check`\n  3162→\n  3163→Checks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n  3164→\n  3165→**Failure response:** `403 Forbidden`\n  3166→\n  3167→---\n  3168→\n  3169→## 7. Rate Limiting Configuration\n  3170→\n  3171→### Rate Limit Tiers\n  3172→\n  3173→| Tier | Limit | Window | Key | Description |\n  3174→|------|-------|--------|-----|-------------|\n  3175→| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n  3176→| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n  3177→| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n  3178→| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n  3179→| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n  3180→| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n  3181→| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n  3182→\n  3183→### Rate Limit Response\n  3184→\n  3185→When a rate limit is exceeded, the server returns `429 Too Many Requests`:\n  3186→\n  3187→```json\n  3188→{\n  3189→  \"message\": \"Too many requests. Please try again later.\",\n  3190→  \"retry_after\": 42\n  3191→}\n  3192→```\n  3193→\n  3194→**Headers included on all rate-limited responses:**\n  3195→\n  3196→| Header | Description |\n  3197→|--------|-------------|\n  3198→| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n  3199→| `X-RateLimit-Remaining` | Requests remaining in the current window |\n  3200→| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n  3201→\n  3202→### Per-Endpoint Rate Limit Mapping\n  3203→\n  3204→| Endpoint Group | Tier |\n  3205→|---------------|------|\n  3206→| `POST /admin/login` | `login` |\n  3207→| `POST /account/login` | `login` |\n  3208→| `POST /api/storefront/v1/carts*` | `api.storefront` |\n  3209→| `GET /api/storefront/v1/carts*` | `api.storefront` |\n  3210→| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n  3211→| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n  3212→| `POST /api/storefront/v1/checkouts*` | `checkout` |\n  3213→| `GET /api/storefront/v1/checkouts*` | `checkout` |\n  3214→| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n  3215→| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n  3216→| `POST /api/storefront/v1/payments*` | `checkout` |\n  3217→| `GET /api/storefront/v1/orders*` | `api.storefront` |\n  3218→| `GET /api/storefront/v1/search` | `search` |\n  3219→| `GET /api/storefront/v1/search/suggest` | `search` |\n  3220→| `POST /api/storefront/v1/analytics/events` | `analytics` |\n  3221→| `GET /api/admin/v1/*` | `api.admin` |\n  3222→| `POST /api/admin/v1/*` | `api.admin` |\n  3223→| `PUT /api/admin/v1/*` | `api.admin` |\n  3224→| `DELETE /api/admin/v1/*` | `api.admin` |\n  3225→| `POST /api/webhooks/stripe` | `webhooks` |\n  3226→| `POST /api/webhooks/paypal` | `webhooks` |\n  3227→\n  3228→---\n  3229→\n  3230→## 8. Checkout State Machine\n  3231→\n  3232→The checkout follows a strict state machine with explicit transitions:\n  3233→\n  3234→```mermaid\n  3235→stateDiagram-v2\n  3236→    [*] --> started : POST /checkouts (create from cart)\n  3237→    started --> addressed : PUT /checkouts/{id}/address\n  3238→    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n  3239→    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n  3240→    payment_pending --> completed : Webhook confirms successful payment\n  3241→    payment_pending --> shipping_selected : Payment failed, customer retries\n  3242→    started --> expired : 24-hour TTL reached\n  3243→    addressed --> expired : 24-hour TTL reached\n  3244→    shipping_selected --> expired : 24-hour TTL reached\n  3245→    payment_pending --> expired : 24-hour TTL reached\n  3246→    completed --> [*]\n  3247→```\n  3248→\n  3249→### Checkout Status Transition Table\n  3250→\n  3251→| From | To | Trigger |\n  3252→|------|----|---------|\n  3253→| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n  3254→| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n  3255→| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n  3256→| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n  3257→| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n  3258→| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n  3259→\n  3260→Applying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n  3261→\n  3262→---\n  3263→\n  3264→## 9. API Versioning Strategy\n  3265→\n  3266→- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n  3267→- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n  3268→- **App API:** Versioned via URL path (`/api/apps/v1/`).\n  3269→- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n  3270→\n  3271→Non-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n  3272→\n  3273→---\n  3274→\n  3275→## 10. Error Response Format\n  3276→\n  3277→All API error responses follow a consistent JSON structure:\n  3278→\n  3279→### Validation Errors (422)\n  3280→\n  3281→```json\n  3282→{\n  3283→  \"message\": \"The given data was invalid.\",\n  3284→  \"errors\": {\n  3285→    \"title\": [\"The title field is required.\"],\n  3286→    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  3287→  }\n  3288→}\n  3289→```\n  3290→\n  3291→### Authorization Errors (403)\n  3292→\n  3293→```json\n  3294→{\n  3295→  \"message\": \"You do not have permission to perform this action.\"\n  3296→}\n  3297→```\n  3298→\n  3299→### Not Found Errors (404)"}]},"uuid":"a527fec8-49c3-4489-9900-55436976541a","timestamp":"2026-02-11T15:19:34.960Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n#### Webhooks\n\nApplied to `/api/webhooks/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:webhooks` | Rate limiting (100/min per IP) |\n\nNo session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)","numLines":200,"startLine":3100,"totalLines":3336}},"sourceToolAssistantUUID":"44915f1b-3d24-486c-ba7a-3c7be4da68a2"}
{"parentUuid":"a527fec8-49c3-4489-9900-55436976541a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EtiQgwCJ2gMEdTFT87bSi3","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":1,"limit":100}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"a659b6e3-0b43-41fb-9d18-814ae437316e","timestamp":"2026-02-11T15:19:35.001Z"}
{"parentUuid":"a659b6e3-0b43-41fb-9d18-814ae437316e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01EtiQgwCJ2gMEdTFT87bSi3","toolUseID":"toolu_01EtiQgwCJ2gMEdTFT87bSi3","timestamp":"2026-02-11T15:19:35.004Z","uuid":"34f2d354-6f5c-45db-8014-5f82c6225089"}
{"parentUuid":"a659b6e3-0b43-41fb-9d18-814ae437316e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EtiQgwCJ2gMEdTFT87bSi3","type":"tool_result","content":"     1→# 07 - Seeders and Test Data Specification\n     2→\n     3→> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n     4→\n     5→---\n     6→\n     7→## 1. DatabaseSeeder Orchestration\n     8→\n     9→The main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n    10→\n    11→**Execution order:**\n    12→\n    13→| Step | Seeder Class | Depends On | Purpose |\n    14→|------|-------------|------------|---------|\n    15→| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n    16→| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n    17→| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n    18→| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n    19→| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n    20→| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n    21→| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n    22→| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n    23→| 9 | CollectionSeeder | Stores | Creates product collections |\n    24→| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n    25→| 11 | DiscountSeeder | Stores | Creates discount codes |\n    26→| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n    27→| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n    28→| 14 | ThemeSeeder | Stores | Creates themes with settings |\n    29→| 15 | PageSeeder | Stores | Creates content pages |\n    30→| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n    31→| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n    32→| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n    33→\n    34→**Dependency diagram (Mermaid):**\n    35→\n    36→```mermaid\n    37→graph TD\n    38→    A[OrganizationSeeder] --> B[StoreSeeder]\n    39→    B --> C[StoreDomainSeeder]\n    40→    B --> F[StoreSettingsSeeder]\n    41→    B --> G[TaxSettingsSeeder]\n    42→    B --> H[ShippingSeeder]\n    43→    B --> I[CollectionSeeder]\n    44→    D[UserSeeder] --> E[StoreUserSeeder]\n    45→    B --> E\n    46→    B --> J[ProductSeeder]\n    47→    I --> J\n    48→    B --> K[DiscountSeeder]\n    49→    B --> L[CustomerSeeder]\n    50→    J --> M[OrderSeeder]\n    51→    L --> M\n    52→    K --> M\n    53→    B --> N[ThemeSeeder]\n    54→    B --> O[PageSeeder]\n    55→    I --> P[NavigationSeeder]\n    56→    O --> P\n    57→    B --> Q[AnalyticsSeeder]\n    58→    L --> Q\n    59→    B --> R[SearchSettingsSeeder]\n    60→```\n    61→\n    62→**Running seeders:**\n    63→- All seeders: `php artisan db:seed`\n    64→- Single seeder: `php artisan db:seed --class=ProductSeeder`\n    65→- Fresh database with seed data: `php artisan migrate:fresh --seed`\n    66→\n    67→---\n    68→\n    69→## 2. Factory Definitions\n    70→\n    71→Each factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n    72→\n    73→---\n    74→\n    75→### 2.1 OrganizationFactory\n    76→\n    77→**Model:** Organization\n    78→\n    79→**Default definition:**\n    80→\n    81→| Field | Generation Rule |\n    82→|-------|----------------|\n    83→| name | Faker company name |\n    84→| billing_email | Faker company email |\n    85→\n    86→**States:** None.\n    87→\n    88→---\n    89→\n    90→### 2.2 StoreFactory\n    91→\n    92→**Model:** Store\n    93→\n    94→**Default definition:**\n    95→\n    96→| Field | Generation Rule |\n    97→|-------|----------------|\n    98→| organization_id | Creates a new Organization via its factory |\n    99→| name | Faker company name + \" Store\" suffix |\n   100→| handle | Faker unique slug (2 words) |"}]},"uuid":"76972144-c649-4372-9b5d-aaf1a9d9fe19","timestamp":"2026-02-11T15:19:35.004Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |","numLines":100,"startLine":1,"totalLines":2215}},"sourceToolAssistantUUID":"a659b6e3-0b43-41fb-9d18-814ae437316e"}
{"parentUuid":"76972144-c649-4372-9b5d-aaf1a9d9fe19","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014mc2ZXzZRJ6jqQi4g4joPf","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":1,"limit":50}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"c2bc8f67-babe-4d69-86a9-354f6fa22e71","timestamp":"2026-02-11T15:19:35.012Z"}
{"parentUuid":"c2bc8f67-babe-4d69-86a9-354f6fa22e71","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_014mc2ZXzZRJ6jqQi4g4joPf","toolUseID":"toolu_014mc2ZXzZRJ6jqQi4g4joPf","timestamp":"2026-02-11T15:19:35.014Z","uuid":"8b5cada5-af15-4100-b862-467538cf7774"}
{"parentUuid":"c2bc8f67-babe-4d69-86a9-354f6fa22e71","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014mc2ZXzZRJ6jqQi4g4joPf","type":"tool_result","content":"     1→# 08 - Playwright E2E Test Plan\n     2→\n     3→> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n     4→\n     5→---\n     6→\n     7→## Test Suite Summary\n     8→\n     9→| Suite | File | Tests | Purpose | Dependencies |\n    10→|-------|------|:-----:|---------|--------------|\n    11→| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n    12→| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n    13→| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n    14→| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |\n    15→| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n    16→| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n    17→| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n    18→| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n    19→| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |\n    20→| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n    21→| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n    22→| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n    23→| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n    24→| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n    25→| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n    26→| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n    27→| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n    28→| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n    29→| **Total** | **18 files** | **134** | | |\n    30→\n    31→---\n    32→\n    33→## Test Infrastructure Requirements\n    34→\n    35→### Framework\n    36→\n    37→- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n    38→- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n    39→- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n    40→- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n    41→\n    42→### Pest v4 Browser Testing API Reference\n    43→\n    44→The coding agent should use these Pest v4 browser methods:\n    45→\n    46→| Method | Purpose |\n    47→|--------|---------|\n    48→| `visit(url)` | Open a real browser and navigate to the given URL |\n    49→| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n    50→| `fill(field, value)` | Type a value into a named form field |"}]},"uuid":"822c4aa9-6418-4bf1-baa3-b4bc0c601185","timestamp":"2026-02-11T15:19:35.013Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **134** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |","numLines":50,"startLine":1,"totalLines":2977}},"sourceToolAssistantUUID":"c2bc8f67-babe-4d69-86a9-354f6fa22e71"}
{"parentUuid":"822c4aa9-6418-4bf1-baa3-b4bc0c601185","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WPNf4yLWx42JKojfXaRd7i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JFxS7fiWBYokx18F5taZMk","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1,"limit":50}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12691,"cache_read_input_tokens":49118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12691},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WKGwfBSTQxXzS2zh4P","type":"assistant","uuid":"2190fc2a-7b78-4cbd-ac86-058935c2320b","timestamp":"2026-02-11T15:19:35.032Z"}
{"parentUuid":"2190fc2a-7b78-4cbd-ac86-058935c2320b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01JFxS7fiWBYokx18F5taZMk","toolUseID":"toolu_01JFxS7fiWBYokx18F5taZMk","timestamp":"2026-02-11T15:19:35.034Z","uuid":"d85e4b0b-3696-451d-9b1c-d2c7eaac51f3"}
{"parentUuid":"2190fc2a-7b78-4cbd-ac86-058935c2320b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JFxS7fiWBYokx18F5taZMk","type":"tool_result","content":"     1→# 09 - Implementation Roadmap and Pest Test Specification\n     2→\n     3→> Shopify-clone 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 | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\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]"}]},"uuid":"4c67b386-a93c-4cb7-a676-bb46f4056084","timestamp":"2026-02-11T15:19:35.034Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| Payments | Stripe + PayPal | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\n| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]","numLines":50,"startLine":1,"totalLines":2027}},"sourceToolAssistantUUID":"2190fc2a-7b78-4cbd-ac86-058935c2320b"}
{"parentUuid":"4c67b386-a93c-4cb7-a676-bb46f4056084","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G8WHPPpPr5UQtBTNTJv8eC","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a good picture of what needs to be updated. Let me create the remaining tasks and start editing. I need to:\n\n1. Finish Task #3 (02-API-ROUTES.md) - there are several remaining Stripe/PayPal/webhook references\n2. Update 03-ADMIN-UI.md \n3. Update 04-STOREFRONT-UI.md\n4. Update 07-SEEDERS-AND-TEST-DATA.md\n5. Update 08-PLAYWRIGHT-E2E-PLAN.md\n6. Update 09-IMPLEMENTATION-ROADMAP.md\n\nLet me first create the remaining tasks, then continue with the edits. Let me read more sections I need to understand before editing.","signature":"Eq8FCkYICxgCKkBsHVwHC+IcIsR2qUzBqBp6kPs8smTCz+B9pdmYHk2EWG1bS9Hq7RyWrcf9UCAQMukbReHSzKV+ayt+sfMJE4R8Egx1eV8e4NUO+WSFZMwaDCEKLve28Q4XzZulaSIwAXHFTacs0CLAVcFKJzVApyTiouqa2v7I/tBzpwaNGgm8lWxVMrmVWgmWMgd5V5LiKpYELDvZPsIeJr9GjLKC5JdT+OxvpqRoqfgT63aw+AbIPs00W0IPfRILvol7ApDWL2/Vt23qYr0+FwU490ps74BEiffXRUtpr0q1heMfdmlQBNBCCzBxLrI86tqcOWgBIapwI+DLWeYlWtUYZY9DHvDkTtGXZXom0GKuB3sEwep/mJXxBIGGuVziNgRuBvB8JnIXmZHBwTFh9k9WlKfTRWi8D1OlwPDsuh36UfuhEX13Vcdk4zGMKzzkv3USpUfuIdW+e8I9UvhPdidTGG6K5wpnYNu0YqVhNOuPj/FO47qi2+mE+g4hu/OK3N3137h7brRNpfGH4xUuBX6meDl6QYv3zFz8bSVNNLYNj/ny0kPdtRlhtEClKwwdiobJqmJcTx2+//oCVrwvj+ltf956ZIc34yl2tSjDuSpLQ89N1WQi0K6R/nuB1spwmi9tPyejNnwtqpDS06+PMR6cMbIHoLplZGXngIlYUB1E2aElsjMP90vchLqjLu2jhKX9WNdsHKCIvdU9gOKy76JnMedvQmbWJqQlzFb+AjoCPYNHYqx02Npueh+Jhv1mqJ9qtU7ZPypTEu9F/FJvzkuxsBhmtC6g4cUJM7PknyvNSPkI57EfldM3oAwwKMAZcUp71NwYGM4VBa9psYh6Te3M78w+tsq2PsxbfJGI8CMjPsjawRmxQ+pYvJQnevfcRGFDXunuoKMY/2ACOWsrGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12231,"cache_read_input_tokens":61809,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12231},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WLtV7953r4tXCd6f2D","type":"assistant","uuid":"96cbdfe2-b93b-4f8e-9b82-d197d08e29e7","timestamp":"2026-02-11T15:19:40.510Z"}
{"parentUuid":"96cbdfe2-b93b-4f8e-9b82-d197d08e29e7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G8WHPPpPr5UQtBTNTJv8eC","type":"message","role":"assistant","content":[{"type":"text","text":"Let me create tasks for the remaining work and read more sections I need to edit."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12231,"cache_read_input_tokens":61809,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12231},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WLtV7953r4tXCd6f2D","type":"assistant","uuid":"52f5860f-da7b-473a-bf59-3cddd232d29d","timestamp":"2026-02-11T15:19:41.285Z"}
{"parentUuid":"52f5860f-da7b-473a-bf59-3cddd232d29d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G8WHPPpPr5UQtBTNTJv8eC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UBQagpJ5aPgU1UaPLr4Cwi","name":"TaskCreate","input":{"subject":"Update 03-ADMIN-UI.md - Payment method display, confirm payment, fulfillment guard","description":"Add payment_method display in order detail, \"Confirm Payment\" button for bank_transfer, fulfillment guard callout, \"Mark as shipped\"/\"Mark as delivered\" buttons on fulfillment cards","activeForm":"Updating 03-ADMIN-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12231,"cache_read_input_tokens":61809,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12231},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WLtV7953r4tXCd6f2D","type":"assistant","uuid":"6e5140f2-144c-49ff-8bdb-8deb4100795f","timestamp":"2026-02-11T15:19:44.121Z"}
{"parentUuid":"6e5140f2-144c-49ff-8bdb-8deb4100795f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UBQagpJ5aPgU1UaPLr4Cwi","type":"tool_result","content":"Task #4 created successfully: Update 03-ADMIN-UI.md - Payment method display, confirm payment, fulfillment guard"}]},"uuid":"b55303df-ce63-4809-98fd-0808bfc827f0","timestamp":"2026-02-11T15:19:44.123Z","toolUseResult":{"task":{"id":"4","subject":"Update 03-ADMIN-UI.md - Payment method display, confirm payment, fulfillment guard"}},"sourceToolAssistantUUID":"6e5140f2-144c-49ff-8bdb-8deb4100795f"}
{"parentUuid":"b55303df-ce63-4809-98fd-0808bfc827f0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G8WHPPpPr5UQtBTNTJv8eC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HzDHFXeg4zvsp943fx9XEN","name":"TaskCreate","input":{"subject":"Update 04-STOREFRONT-UI.md - Replace checkout Step 4 with payment method selection","description":"Replace checkout Step 4 (Payment) with payment method selection (radio buttons) and onsite forms for credit card, PayPal, bank transfer. Update order confirmation page.","activeForm":"Updating 04-STOREFRONT-UI.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12231,"cache_read_input_tokens":61809,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12231},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WLtV7953r4tXCd6f2D","type":"assistant","uuid":"2194fc18-f25c-4df4-99fc-91f445bba097","timestamp":"2026-02-11T15:19:45.958Z"}
{"parentUuid":"2194fc18-f25c-4df4-99fc-91f445bba097","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HzDHFXeg4zvsp943fx9XEN","type":"tool_result","content":"Task #5 created successfully: Update 04-STOREFRONT-UI.md - Replace checkout Step 4 with payment method selection"}]},"uuid":"e726013c-cc60-4b32-95f3-73bf524aca85","timestamp":"2026-02-11T15:19:45.960Z","toolUseResult":{"task":{"id":"5","subject":"Update 04-STOREFRONT-UI.md - Replace checkout Step 4 with payment method selection"}},"sourceToolAssistantUUID":"2194fc18-f25c-4df4-99fc-91f445bba097"}
{"parentUuid":"e726013c-cc60-4b32-95f3-73bf524aca85","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G8WHPPpPr5UQtBTNTJv8eC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QLbgtKwKHe3HyCuTnecqpM","name":"TaskCreate","input":{"subject":"Update 07-SEEDERS-AND-TEST-DATA.md - Add payment_method to orders, update factories","description":"Add payment_method to all seeded orders, add bank_transfer and paypal orders, update PaymentFactory provider to mock, update OrderFactory","activeForm":"Updating 07-SEEDERS-AND-TEST-DATA.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12231,"cache_read_input_tokens":61809,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12231},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WLtV7953r4tXCd6f2D","type":"assistant","uuid":"119d2868-c2ee-4d06-97e8-29ce13717d81","timestamp":"2026-02-11T15:19:48.008Z"}
{"parentUuid":"119d2868-c2ee-4d06-97e8-29ce13717d81","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QLbgtKwKHe3HyCuTnecqpM","type":"tool_result","content":"Task #6 created successfully: Update 07-SEEDERS-AND-TEST-DATA.md - Add payment_method to orders, update factories"}]},"uuid":"da7c1140-09b0-4d1b-9034-3ae04bbfea78","timestamp":"2026-02-11T15:19:48.013Z","toolUseResult":{"task":{"id":"6","subject":"Update 07-SEEDERS-AND-TEST-DATA.md - Add payment_method to orders, update factories"}},"sourceToolAssistantUUID":"119d2868-c2ee-4d06-97e8-29ce13717d81"}
{"parentUuid":"da7c1140-09b0-4d1b-9034-3ae04bbfea78","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G8WHPPpPr5UQtBTNTJv8eC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CJadqSHAkDGMYpWEyCz68J","name":"TaskCreate","input":{"subject":"Update 08-PLAYWRIGHT-E2E-PLAN.md - Update checkout tests for mock PSP","description":"Update checkout flow tests for payment method selection, add tests per payment method, magic card decline test, bank transfer admin confirmation test","activeForm":"Updating 08-PLAYWRIGHT-E2E-PLAN.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12231,"cache_read_input_tokens":61809,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12231},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WLtV7953r4tXCd6f2D","type":"assistant","uuid":"9eca8235-31e0-4727-a36b-d71c8e43a3f8","timestamp":"2026-02-11T15:19:49.851Z"}
{"parentUuid":"9eca8235-31e0-4727-a36b-d71c8e43a3f8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CJadqSHAkDGMYpWEyCz68J","type":"tool_result","content":"Task #7 created successfully: Update 08-PLAYWRIGHT-E2E-PLAN.md - Update checkout tests for mock PSP"}]},"uuid":"ba076778-3d71-4aa5-946c-8ac1900c3a3b","timestamp":"2026-02-11T15:19:49.855Z","toolUseResult":{"task":{"id":"7","subject":"Update 08-PLAYWRIGHT-E2E-PLAN.md - Update checkout tests for mock PSP"}},"sourceToolAssistantUUID":"9eca8235-31e0-4727-a36b-d71c8e43a3f8"}
{"parentUuid":"ba076778-3d71-4aa5-946c-8ac1900c3a3b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G8WHPPpPr5UQtBTNTJv8eC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GnRMnYncZ4erszzj6sj9ee","name":"TaskCreate","input":{"subject":"Update 09-IMPLEMENTATION-ROADMAP.md - Replace Stripe/PayPal with MockPaymentProvider","description":"Update Phase 5 and technology decisions to use MockPaymentProvider, remove Stripe/PayPal references, add fulfillment guard and payment-method-specific OMS","activeForm":"Updating 09-IMPLEMENTATION-ROADMAP.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12231,"cache_read_input_tokens":61809,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":12231},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WLtV7953r4tXCd6f2D","type":"assistant","uuid":"3625e395-ac77-4113-8561-fb046f878f0c","timestamp":"2026-02-11T15:19:51.489Z"}
{"parentUuid":"3625e395-ac77-4113-8561-fb046f878f0c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GnRMnYncZ4erszzj6sj9ee","type":"tool_result","content":"Task #8 created successfully: Update 09-IMPLEMENTATION-ROADMAP.md - Replace Stripe/PayPal with MockPaymentProvider"}]},"uuid":"196f665a-7401-4f12-b549-441ff627a17b","timestamp":"2026-02-11T15:19:51.491Z","toolUseResult":{"task":{"id":"8","subject":"Update 09-IMPLEMENTATION-ROADMAP.md - Replace Stripe/PayPal with MockPaymentProvider"}},"sourceToolAssistantUUID":"3625e395-ac77-4113-8561-fb046f878f0c"}
{"parentUuid":"196f665a-7401-4f12-b549-441ff627a17b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01A5yPgARX7TDhzykRopdsy6","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me finish Task #3 (02-API-ROUTES.md) by cleaning up remaining Stripe/PayPal/webhook references. Let me also read the sections I need for the other files."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1190,"cache_read_input_tokens":74040,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1190},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WN6pHrqDRsL69Phiw3","type":"assistant","uuid":"9f42b651-4b25-489a-b953-18fe5b75a37f","timestamp":"2026-02-11T15:19:54.596Z"}
{"parentUuid":"9f42b651-4b25-489a-b953-18fe5b75a37f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01A5yPgARX7TDhzykRopdsy6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Y993qphMH8QqQNZr5XZRJT","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":300,"limit":150}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1190,"cache_read_input_tokens":74040,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1190},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WN6pHrqDRsL69Phiw3","type":"assistant","uuid":"9c5768bb-d990-4f8c-b28e-515a3e07e6a6","timestamp":"2026-02-11T15:19:56.097Z"}
{"parentUuid":"9c5768bb-d990-4f8c-b28e-515a3e07e6a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01Y993qphMH8QqQNZr5XZRJT","toolUseID":"toolu_01Y993qphMH8QqQNZr5XZRJT","timestamp":"2026-02-11T15:19:56.103Z","uuid":"27c9e05d-cd0a-4baa-8a78-e921c16b1a05"}
{"parentUuid":"9c5768bb-d990-4f8c-b28e-515a3e07e6a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Y993qphMH8QqQNZr5XZRJT","type":"tool_result","content":"   300→**Model:** CartLine\n   301→\n   302→**Default definition:**\n   303→\n   304→| Field | Generation Rule |\n   305→|-------|----------------|\n   306→| cart_id | Creates a new Cart via its factory |\n   307→| variant_id | Creates a new ProductVariant via its factory |\n   308→| quantity | Random 1-5 |\n   309→| unit_price_amount | Random 999-19999 |\n   310→| line_subtotal_amount | Computed: unit_price_amount * quantity |\n   311→| line_discount_amount | 0 |\n   312→| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n   313→\n   314→**States:** None.\n   315→\n   316→---\n   317→\n   318→### 2.11 CheckoutFactory\n   319→\n   320→**Model:** Checkout\n   321→\n   322→**Default definition:**\n   323→\n   324→| Field | Generation Rule |\n   325→|-------|----------------|\n   326→| store_id | Creates a new Store via its factory |\n   327→| cart_id | Creates a new Cart via its factory |\n   328→| customer_id | null |\n   329→| status | \"started\" |\n   330→| email | Faker safe email |\n   331→| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n   332→| billing_address_json | null |\n   333→| shipping_method_id | null |\n   334→| discount_code | null |\n   335→| tax_provider_snapshot_json | null |\n   336→| totals_json | null |\n   337→| expires_at | 24 hours from now |\n   338→\n   339→**States:**\n   340→\n   341→| State Name | Overrides |\n   342→|-----------|-----------|\n   343→| `completed` | status = \"completed\" |\n   344→| `expired` | status = \"expired\", expires_at = 1 hour ago |\n   345→\n   346→---\n   347→\n   348→### 2.12 OrderFactory\n   349→\n   350→**Model:** Order\n   351→\n   352→**Default definition:**\n   353→\n   354→| Field | Generation Rule |\n   355→|-------|----------------|\n   356→| store_id | Creates a new Store via its factory |\n   357→| customer_id | Creates a new Customer via its factory |\n   358→| order_number | \"#\" + Faker unique integer 1001-9999 |\n   359→| status | \"paid\" |\n   360→| financial_status | \"paid\" |\n   361→| fulfillment_status | \"unfulfilled\" |\n   362→| currency | \"EUR\" |\n   363→| subtotal_amount | 4998 |\n   364→| discount_amount | 0 |\n   365→| shipping_amount | 499 |\n   366→| tax_amount | 798 |\n   367→| total_amount | 5497 |\n   368→| email | Faker safe email |\n   369→| billing_address_json | JSON address object with German Faker data |\n   370→| shipping_address_json | JSON address object with German Faker data |\n   371→| placed_at | Current timestamp |\n   372→\n   373→**States:**\n   374→\n   375→| State Name | Overrides |\n   376→|-----------|-----------|\n   377→| `pending` | status = \"pending\", financial_status = \"pending\" |\n   378→| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n   379→| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n   380→| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n   381→| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n   382→\n   383→---\n   384→\n   385→### 2.13 OrderLineFactory\n   386→\n   387→**Model:** OrderLine\n   388→\n   389→**Default definition:**\n   390→\n   391→| Field | Generation Rule |\n   392→|-------|----------------|\n   393→| order_id | Creates a new Order via its factory |\n   394→| product_id | Creates a new Product via its factory |\n   395→| variant_id | Creates a new ProductVariant via its factory |\n   396→| title_snapshot | Faker 3 words |\n   397→| sku_snapshot | Faker pattern \"SKU-####-???\" |\n   398→| quantity | Random 1-3 |\n   399→| unit_price_amount | Random 999-19999 |\n   400→| total_amount | Computed: unit_price_amount * quantity |\n   401→| tax_lines_json | Empty JSON array |\n   402→| discount_allocations_json | Empty JSON array |\n   403→\n   404→**States:** None.\n   405→\n   406→---\n   407→\n   408→### 2.14 PaymentFactory\n   409→\n   410→**Model:** Payment\n   411→\n   412→**Default definition:**\n   413→\n   414→| Field | Generation Rule |\n   415→|-------|----------------|\n   416→| order_id | Creates a new Order via its factory |\n   417→| provider | \"stripe\" |\n   418→| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n   419→| status | \"captured\" |\n   420→| amount | Random 999-99999 |\n   421→| currency | \"EUR\" |\n   422→| raw_json_encrypted | null |\n   423→\n   424→**States:**\n   425→\n   426→| State Name | Overrides |\n   427→|-----------|-----------|\n   428→| `requiresAction` | status = \"requires_action\" |\n   429→| `authorized` | status = \"authorized\" |\n   430→| `failed` | status = \"failed\" |\n   431→| `refunded` | status = \"refunded\" |\n   432→\n   433→---\n   434→\n   435→### 2.15 RefundFactory\n   436→\n   437→**Model:** Refund\n   438→\n   439→**Default definition:**\n   440→\n   441→| Field | Generation Rule |\n   442→|-------|----------------|\n   443→| order_id | Creates a new Order via its factory |\n   444→| payment_id | Creates a new Payment via its factory |\n   445→| amount | Random 999-19999 |\n   446→| reason | Faker sentence |\n   447→| status | \"processed\" |\n   448→| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n   449→"}]},"uuid":"4bf1904a-f2e3-4c50-8c35-486cb2e8c379","timestamp":"2026-02-11T15:19:56.103Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"stripe\" |\n| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `requiresAction` | status = \"requires_action\" |\n| `authorized` | status = \"authorized\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n","numLines":150,"startLine":300,"totalLines":2215}},"sourceToolAssistantUUID":"9c5768bb-d990-4f8c-b28e-515a3e07e6a6"}
{"parentUuid":"4bf1904a-f2e3-4c50-8c35-486cb2e8c379","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01A5yPgARX7TDhzykRopdsy6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017jvDaFqX9uuRRyCMAGJTm3","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","offset":1200,"limit":800}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1190,"cache_read_input_tokens":74040,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1190},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WN6pHrqDRsL69Phiw3","type":"assistant","uuid":"b3b945a5-8742-4961-abb0-da815307ba1d","timestamp":"2026-02-11T15:19:56.811Z"}
{"parentUuid":"b3b945a5-8742-4961-abb0-da815307ba1d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_017jvDaFqX9uuRRyCMAGJTm3","toolUseID":"toolu_017jvDaFqX9uuRRyCMAGJTm3","timestamp":"2026-02-11T15:19:56.993Z","uuid":"9bf6e480-a94f-4ace-8923-a7ece8a4af1b"}
{"parentUuid":"b3b945a5-8742-4961-abb0-da815307ba1d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017jvDaFqX9uuRRyCMAGJTm3","type":"tool_result","content":"  1200→| Field | Value |\n  1201→|-------|-------|\n  1202→| handle | canvas-tote-bag |\n  1203→| status | active |\n  1204→| vendor | Acme Accessories |\n  1205→| product_type | Accessories |\n  1206→| tags | [\"trending\"] |\n  1207→| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n  1208→| published_at | now |\n  1209→\n  1210→Collections: (none)\n  1211→\n  1212→Options: Color (Natural, Black)\n  1213→\n  1214→Variants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n  1215→\n  1216→---\n  1217→\n  1218→**Product 14: \"Bucket Hat\"**\n  1219→\n  1220→| Field | Value |\n  1221→|-------|-------|\n  1222→| handle | bucket-hat |\n  1223→| status | active |\n  1224→| vendor | Acme Accessories |\n  1225→| product_type | Accessories |\n  1226→| tags | [\"new\", \"trending\"] |\n  1227→| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n  1228→| published_at | now |\n  1229→\n  1230→Collections: New Arrivals\n  1231→\n  1232→Options: Size (S/M, L/XL), Color (Beige, Black, Olive)\n  1233→\n  1234→Variants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n  1235→\n  1236→---\n  1237→\n  1238→**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n  1239→\n  1240→| Field | Value |\n  1241→|-------|-------|\n  1242→| handle | unreleased-winter-jacket |\n  1243→| status | **draft** |\n  1244→| vendor | Acme Outerwear |\n  1245→| product_type | Jackets |\n  1246→| tags | [\"limited\"] |\n  1247→| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n  1248→| published_at | **null** |\n  1249→\n  1250→Collections: (none - draft not assigned)\n  1251→\n  1252→Options: Size (S, M, L, XL)\n  1253→\n  1254→Variants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n  1255→\n  1256→**Test purpose:** Verifies draft products do not appear on the storefront.\n  1257→\n  1258→---\n  1259→\n  1260→**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n  1261→\n  1262→| Field | Value |\n  1263→|-------|-------|\n  1264→| handle | discontinued-raincoat |\n  1265→| status | **archived** |\n  1266→| vendor | Acme Outerwear |\n  1267→| product_type | Jackets |\n  1268→| tags | [] |\n  1269→| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n  1270→| published_at | 6 months ago |\n  1271→\n  1272→Collections: (none)\n  1273→\n  1274→Options: Size (M, L)\n  1275→\n  1276→Variants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n  1277→\n  1278→**Test purpose:** Verifies archived products do not appear on the storefront.\n  1279→\n  1280→---\n  1281→\n  1282→**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n  1283→\n  1284→| Field | Value |\n  1285→|-------|-------|\n  1286→| handle | limited-edition-sneakers |\n  1287→| status | active |\n  1288→| vendor | Acme Sport |\n  1289→| product_type | Shoes |\n  1290→| tags | [\"limited\"] |\n  1291→| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n  1292→| published_at | now |\n  1293→\n  1294→Collections: (none)\n  1295→\n  1296→Options: Size (EU 40, EU 42, EU 44)\n  1297→\n  1298→Variants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n  1299→\n  1300→**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n  1301→\n  1302→---\n  1303→\n  1304→**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n  1305→\n  1306→| Field | Value |\n  1307→|-------|-------|\n  1308→| handle | backorder-denim-jacket |\n  1309→| status | active |\n  1310→| vendor | Acme Denim |\n  1311→| product_type | Jackets |\n  1312→| tags | [\"popular\"] |\n  1313→| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n  1314→| published_at | now |\n  1315→\n  1316→Collections: (none)\n  1317→\n  1318→Options: Size (S, M, L, XL)\n  1319→\n  1320→Variants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n  1321→\n  1322→**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n  1323→\n  1324→---\n  1325→\n  1326→**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n  1327→\n  1328→| Field | Value |\n  1329→|-------|-------|\n  1330→| handle | gift-card |\n  1331→| status | active |\n  1332→| vendor | Acme Fashion |\n  1333→| product_type | Gift Cards |\n  1334→| tags | [\"popular\"] |\n  1335→| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n  1336→| published_at | now |\n  1337→\n  1338→Collections: (none)\n  1339→\n  1340→Options: Amount (25 EUR, 50 EUR, 100 EUR)\n  1341→\n  1342→Variants (3 total - each a different denomination):\n  1343→\n  1344→| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n  1345→|-----|---------------|--------|-------------------|-----------|\n  1346→| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n  1347→| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n  1348→| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n  1349→\n  1350→**Test purpose:** Tests digital product flow (no shipping step in checkout).\n  1351→\n  1352→---\n  1353→\n  1354→**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n  1355→\n  1356→| Field | Value |\n  1357→|-------|-------|\n  1358→| handle | cashmere-overcoat |\n  1359→| status | active |\n  1360→| vendor | Acme Premium |\n  1361→| product_type | Jackets |\n  1362→| tags | [\"limited\", \"new\"] |\n  1363→| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n  1364→| published_at | now |\n  1365→\n  1366→Collections: New Arrivals\n  1367→\n  1368→Options: Size (S, M, L), Color (Camel, Charcoal)\n  1369→\n  1370→Variants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n  1371→\n  1372→**Test purpose:** Tests high-value price display (499.99 EUR).\n  1373→\n  1374→---\n  1375→\n  1376→#### Product Summary Table (Acme Fashion)\n  1377→\n  1378→| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n  1379→|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n  1380→| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n  1381→| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n  1382→| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n  1383→| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n  1384→| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n  1385→| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n  1386→| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n  1387→| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n  1388→| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n  1389→| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n  1390→| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n  1391→| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n  1392→| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n  1393→| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n  1394→| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n  1395→| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n  1396→| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n  1397→| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n  1398→| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n  1399→| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n  1400→\n  1401→**Total variants across Acme Fashion:** 107\n  1402→\n  1403→---\n  1404→\n  1405→#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n  1406→\n  1407→These products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n  1408→\n  1409→| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n  1410→|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n  1411→| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n  1412→| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n  1413→| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n  1414→| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n  1415→| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n  1416→\n  1417→All active, published now, tags vary. Inventory policy = \"deny\" for all.\n  1418→\n  1419→**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n  1420→\n  1421→---\n  1422→\n  1423→### 3.11 DiscountSeeder\n  1424→\n  1425→**Acme Fashion - 5 discount codes:**\n  1426→\n  1427→| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n  1428→|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n  1429→| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n  1430→| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n  1431→| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n  1432→| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n  1433→| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n  1434→\n  1435→**Behavioral expectations:**\n  1436→- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n  1437→- FLAT5: Applies flat 5.00 EUR discount. Never used.\n  1438→- FREESHIP: Removes shipping cost. Used once.\n  1439→- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n  1440→- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n  1441→\n  1442→---\n  1443→\n  1444→### 3.12 CustomerSeeder\n  1445→\n  1446→**Acme Fashion - 10 customers:**\n  1447→\n  1448→| # | Email | Name | Password (plaintext) | marketing_opt_in |\n  1449→|---|-------|------|----------------------|------------------|\n  1450→| 1 | customer@acme.test | John Doe | password | 1 |\n  1451→| 2 | jane@example.com | Jane Smith | password | 0 |\n  1452→| 3 | michael@example.com | Michael Brown | password | 1 |\n  1453→| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n  1454→| 5 | david@example.com | David Lee | password | 1 |\n  1455→| 6 | emma@example.com | Emma Garcia | password | 0 |\n  1456→| 7 | james@example.com | James Taylor | password | 0 |\n  1457→| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n  1458→| 9 | robert@example.com | Robert Martinez | password | 0 |\n  1459→| 10 | anna@example.com | Anna Thomas | password | 1 |\n  1460→\n  1461→**Customer 1 (customer@acme.test) addresses:**\n  1462→\n  1463→| Label | is_default | Address Details |\n  1464→|-------|-----------|-----------------|\n  1465→| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n  1466→| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n  1467→\n  1468→**Customer 2 (jane@example.com) addresses:**\n  1469→\n  1470→| Label | is_default | Address Details |\n  1471→|-------|-----------|-----------------|\n  1472→| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n  1473→\n  1474→**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n  1475→\n  1476→**Acme Electronics - 2 customers (tenant isolation testing):**\n  1477→\n  1478→| # | Email | Name | Password |\n  1479→|---|-------|------|----------|\n  1480→| 1 | techfan@example.com | Tech Fan | password |\n  1481→| 2 | gadgetlover@example.com | Gadget Lover | password |\n  1482→\n  1483→Each has one default address with Faker-generated data.\n  1484→\n  1485→---\n  1486→\n  1487→### 3.13 OrderSeeder\n  1488→\n  1489→**Acme Fashion - 15 orders:**\n  1490→\n  1491→All orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n  1492→\n  1493→---\n  1494→\n  1495→**Order #1001 - Awaiting fulfillment**\n  1496→\n  1497→| Field | Value |\n  1498→|-------|-------|\n  1499→| customer | customer@acme.test (John Doe) |\n  1500→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1501→| placed_at | 2 days ago |\n  1502→\n  1503→Lines:\n  1504→\n  1505→| Product | Variant (option values) | Qty | Unit Price | Line Total |\n  1506→|---------|------------------------|-----|-----------|-----------|\n  1507→| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n  1508→\n  1509→Totals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n  1510→\n  1511→Payment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n  1512→\n  1513→Fulfillments: None.\n  1514→\n  1515→---\n  1516→\n  1517→**Order #1002 - Fully delivered**\n  1518→\n  1519→| Field | Value |\n  1520→|-------|-------|\n  1521→| customer | customer@acme.test (John Doe) |\n  1522→| status / financial / fulfillment | fulfilled / paid / fulfilled |\n  1523→| placed_at | 10 days ago |\n  1524→\n  1525→Lines:\n  1526→\n  1527→| Product | Variant | Qty | Unit Price | Line Total |\n  1528→|---------|---------|-----|-----------|-----------|\n  1529→| Organic Hoodie | M | 1 | 5999 | 5999 |\n  1530→| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n  1531→\n  1532→Totals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n  1533→\n  1534→Payment: stripe, \"pi_test_order1002\", captured, 8997\n  1535→\n  1536→Fulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n  1537→\n  1538→---\n  1539→\n  1540→**Order #1003 - Partially fulfilled**\n  1541→\n  1542→| Field | Value |\n  1543→|-------|-------|\n  1544→| customer | jane@example.com (Jane Smith) |\n  1545→| status / financial / fulfillment | paid / paid / partial |\n  1546→| placed_at | 5 days ago |\n  1547→\n  1548→Lines:\n  1549→\n  1550→| Product | Variant | Qty | Unit Price | Line Total |\n  1551→|---------|---------|-----|-----------|-----------|\n  1552→| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n  1553→| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n  1554→\n  1555→Totals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n  1556→\n  1557→Payment: stripe, \"pi_test_order1003\", captured, 11997\n  1558→\n  1559→Fulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n  1560→\n  1561→---\n  1562→\n  1563→**Order #1004 - Cancelled with full refund**\n  1564→\n  1565→| Field | Value |\n  1566→|-------|-------|\n  1567→| customer | customer@acme.test (John Doe) |\n  1568→| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n  1569→| placed_at | 15 days ago |\n  1570→\n  1571→Lines:\n  1572→\n  1573→| Product | Variant | Qty | Unit Price | Line Total |\n  1574→|---------|---------|-----|-----------|-----------|\n  1575→| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n  1576→\n  1577→Totals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n  1578→\n  1579→Payment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n  1580→\n  1581→Refund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n  1582→\n  1583→---\n  1584→\n  1585→**Order #1005 - Payment pending**\n  1586→\n  1587→| Field | Value |\n  1588→|-------|-------|\n  1589→| customer | jane@example.com (Jane Smith) |\n  1590→| status / financial / fulfillment | pending / pending / unfulfilled |\n  1591→| placed_at | 2 hours ago |\n  1592→\n  1593→Lines:\n  1594→\n  1595→| Product | Variant | Qty | Unit Price | Line Total |\n  1596→|---------|---------|-----|-----------|-----------|\n  1597→| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n  1598→\n  1599→Totals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n  1600→\n  1601→Payment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n  1602→\n  1603→---\n  1604→\n  1605→**Order #1006 - Standard paid order**\n  1606→\n  1607→| Field | Value |\n  1608→|-------|-------|\n  1609→| customer | michael@example.com (Michael Brown) |\n  1610→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1611→| placed_at | 1 day ago |\n  1612→\n  1613→Lines:\n  1614→\n  1615→| Product | Variant | Qty | Unit Price | Line Total |\n  1616→|---------|---------|-----|-----------|-----------|\n  1617→| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n  1618→\n  1619→Totals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n  1620→\n  1621→Payment: stripe, \"pi_test_order1006\", captured, 12498\n  1622→\n  1623→---\n  1624→\n  1625→**Order #1007 - Multi-item delivered**\n  1626→\n  1627→| Field | Value |\n  1628→|-------|-------|\n  1629→| customer | sarah@example.com (Sarah Wilson) |\n  1630→| status / financial / fulfillment | fulfilled / paid / fulfilled |\n  1631→| placed_at | 20 days ago |\n  1632→\n  1633→Lines:\n  1634→\n  1635→| Product | Variant | Qty | Unit Price | Line Total |\n  1636→|---------|---------|-----|-----------|-----------|\n  1637→| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n  1638→| Wool Scarf | Grey | 1 | 2999 | 2999 |\n  1639→\n  1640→Totals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n  1641→\n  1642→Payment: stripe, captured, 10496\n  1643→\n  1644→Fulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n  1645→\n  1646→---\n  1647→\n  1648→**Order #1008 - Partial refund**\n  1649→\n  1650→| Field | Value |\n  1651→|-------|-------|\n  1652→| customer | david@example.com (David Lee) |\n  1653→| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n  1654→| placed_at | 12 days ago |\n  1655→\n  1656→Lines:\n  1657→\n  1658→| Product | Variant | Qty | Unit Price | Line Total |\n  1659→|---------|---------|-----|-----------|-----------|\n  1660→| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n  1661→| Graphic Print Tee | L | 1 | 2999 | 2999 |\n  1662→\n  1663→Totals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n  1664→\n  1665→Payment: stripe, captured, 8997\n  1666→\n  1667→Fulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n  1668→\n  1669→Refund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n  1670→\n  1671→---\n  1672→\n  1673→**Order #1009 - Accessories order**\n  1674→\n  1675→| Field | Value |\n  1676→|-------|-------|\n  1677→| customer | emma@example.com (Emma Garcia) |\n  1678→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1679→| placed_at | 3 days ago |\n  1680→\n  1681→Lines:\n  1682→\n  1683→| Product | Variant | Qty | Unit Price | Line Total |\n  1684→|---------|---------|-----|-----------|-----------|\n  1685→| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n  1686→| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n  1687→\n  1688→Totals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n  1689→\n  1690→Payment: stripe, captured, 4997\n  1691→\n  1692→---\n  1693→\n  1694→**Order #1010 - High-value order**\n  1695→\n  1696→| Field | Value |\n  1697→|-------|-------|\n  1698→| customer | customer@acme.test (John Doe) |\n  1699→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1700→| placed_at | 1 day ago |\n  1701→\n  1702→Lines:\n  1703→\n  1704→| Product | Variant | Qty | Unit Price | Line Total |\n  1705→|---------|---------|-----|-----------|-----------|\n  1706→| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n  1707→\n  1708→Totals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n  1709→\n  1710→Payment: stripe, captured, 50498\n  1711→\n  1712→---\n  1713→\n  1714→**Order #1011 - Single item delivered**\n  1715→\n  1716→| Field | Value |\n  1717→|-------|-------|\n  1718→| customer | james@example.com (James Taylor) |\n  1719→| status / financial / fulfillment | paid / paid / fulfilled |\n  1720→| placed_at | 25 days ago |\n  1721→\n  1722→Lines:\n  1723→\n  1724→| Product | Variant | Qty | Unit Price | Line Total |\n  1725→|---------|---------|-----|-----------|-----------|\n  1726→| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n  1727→\n  1728→Totals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n  1729→\n  1730→Payment: stripe, captured, 3298\n  1731→\n  1732→Fulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n  1733→\n  1734→---\n  1735→\n  1736→**Order #1012 - Multi-quantity order**\n  1737→\n  1738→| Field | Value |\n  1739→|-------|-------|\n  1740→| customer | lisa@example.com (Lisa Anderson) |\n  1741→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1742→| placed_at | 4 days ago |\n  1743→\n  1744→Lines:\n  1745→\n  1746→| Product | Variant | Qty | Unit Price | Line Total |\n  1747→|---------|---------|-----|-----------|-----------|\n  1748→| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n  1749→\n  1750→Totals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n  1751→\n  1752→Payment: stripe, captured, 8497\n  1753→\n  1754→---\n  1755→\n  1756→**Order #1013 - Multi-item order**\n  1757→\n  1758→| Field | Value |\n  1759→|-------|-------|\n  1760→| customer | robert@example.com (Robert Martinez) |\n  1761→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1762→| placed_at | 1 day ago |\n  1763→\n  1764→Lines:\n  1765→\n  1766→| Product | Variant | Qty | Unit Price | Line Total |\n  1767→|---------|---------|-----|-----------|-----------|\n  1768→| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n  1769→| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n  1770→\n  1771→Totals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n  1772→\n  1773→Payment: stripe, captured, 8497\n  1774→\n  1775→---\n  1776→\n  1777→**Order #1014 - Digital product order**\n  1778→\n  1779→| Field | Value |\n  1780→|-------|-------|\n  1781→| customer | anna@example.com (Anna Thomas) |\n  1782→| status / financial / fulfillment | paid / paid / fulfilled |\n  1783→| placed_at | 14 days ago |\n  1784→\n  1785→Lines:\n  1786→\n  1787→| Product | Variant | Qty | Unit Price | Line Total |\n  1788→|---------|---------|-----|-----------|-----------|\n  1789→| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n  1790→\n  1791→Totals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n  1792→\n  1793→Payment: stripe, captured, 5000\n  1794→\n  1795→Fulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n  1796→\n  1797→---\n  1798→\n  1799→**Order #1015 - Order with discount**\n  1800→\n  1801→| Field | Value |\n  1802→|-------|-------|\n  1803→| customer | customer@acme.test (John Doe) |\n  1804→| status / financial / fulfillment | paid / paid / unfulfilled |\n  1805→| placed_at | now (today) |\n  1806→\n  1807→Lines:\n  1808→\n  1809→| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n  1810→|---------|---------|-----|-----------|-----------|-------------------|\n  1811→| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n  1812→| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n  1813→\n  1814→Totals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n  1815→\n  1816→Payment: stripe, captured, 5447\n  1817→\n  1818→The discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n  1819→\n  1820→---\n  1821→\n  1822→#### Order Summary Table (Acme Fashion)\n  1823→\n  1824→| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n  1825→|-------|----------|--------|-----------|-------------|-------|-------------------|\n  1826→| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n  1827→| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n  1828→| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n  1829→| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n  1830→| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n  1831→| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n  1832→| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n  1833→| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n  1834→| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n  1835→| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n  1836→| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n  1837→| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n  1838→| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n  1839→| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n  1840→| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n  1841→\n  1842→---\n  1843→\n  1844→**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n  1845→\n  1846→| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n  1847→|-------|----------|--------|-----------|-------------|-------|---------|\n  1848→| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n  1849→| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n  1850→| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n  1851→\n  1852→---\n  1853→\n  1854→### 3.14 ThemeSeeder\n  1855→\n  1856→**Acme Fashion:**\n  1857→\n  1858→| Field | Value |\n  1859→|-------|-------|\n  1860→| name | Default Theme |\n  1861→| version | 1.0.0 |\n  1862→| status | published |\n  1863→| published_at | now |\n  1864→\n  1865→**Theme settings (theme_settings row):**\n  1866→\n  1867→| Setting Key | Value |\n  1868→|-------------|-------|\n  1869→| primary_color | #1a1a2e |\n  1870→| secondary_color | #e94560 |\n  1871→| font_family | Inter, sans-serif |\n  1872→| hero_heading | Welcome to Acme Fashion |\n  1873→| hero_subheading | Discover our curated collection of modern essentials |\n  1874→| hero_cta_text | Shop New Arrivals |\n  1875→| hero_cta_link | /collections/new-arrivals |\n  1876→| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n  1877→| footer_text | 2025 Acme Fashion. All rights reserved. |\n  1878→| show_announcement_bar | true |\n  1879→| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n  1880→| products_per_page | 12 |\n  1881→| show_vendor | true |\n  1882→| show_quantity_selector | true |\n  1883→\n  1884→**Acme Electronics:**\n  1885→\n  1886→| Field | Value |\n  1887→|-------|-------|\n  1888→| name | Default Theme |\n  1889→| version | 1.0.0 |\n  1890→| status | published |\n  1891→\n  1892→Theme settings:\n  1893→\n  1894→| Setting Key | Value |\n  1895→|-------------|-------|\n  1896→| primary_color | #0f172a |\n  1897→| secondary_color | #3b82f6 |\n  1898→| font_family | Inter, sans-serif |\n  1899→| hero_heading | Acme Electronics |\n  1900→| hero_subheading | Premium tech for professionals |\n  1901→| hero_cta_text | Shop Featured |\n  1902→| hero_cta_link | /collections/featured |\n  1903→| featured_collection_handles | [\"featured\"] |\n  1904→| footer_text | 2025 Acme Electronics. All rights reserved. |\n  1905→\n  1906→---\n  1907→\n  1908→### 3.15 PageSeeder\n  1909→\n  1910→**Acme Fashion - 5 content pages:**\n  1911→\n  1912→All pages: status = \"published\", published_at = 3 months ago.\n  1913→\n  1914→**Page 1: About Us**\n  1915→- handle: about\n  1916→- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n  1917→\n  1918→**Page 2: FAQ**\n  1919→- handle: faq\n  1920→- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1921→  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  1922→  2. Return policy (30 days, unworn, original packaging)\n  1923→  3. International shipping (EU + US, UK, CA, AU)\n  1924→  4. Order tracking (email with tracking number after shipment)\n  1925→\n  1926→**Page 3: Shipping & Returns**\n  1927→- handle: shipping-returns\n  1928→- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n  1929→\n  1930→**Page 4: Privacy Policy**\n  1931→- handle: privacy-policy\n  1932→- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n  1933→\n  1934→**Page 5: Terms of Service**\n  1935→- handle: terms\n  1936→- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n  1937→\n  1938→---\n  1939→\n  1940→### 3.16 NavigationSeeder\n  1941→\n  1942→**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n  1943→\n  1944→| Position | Label | Type | Target |\n  1945→|----------|-------|------|--------|\n  1946→| 0 | Home | link | URL: / |\n  1947→| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n  1948→| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n  1949→| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n  1950→| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n  1951→\n  1952→**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n  1953→\n  1954→| Position | Label | Type | Target |\n  1955→|----------|-------|------|--------|\n  1956→| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n  1957→| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n  1958→| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n  1959→| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n  1960→| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n  1961→\n  1962→**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n  1963→\n  1964→| Position | Label | Type | Target |\n  1965→|----------|-------|------|--------|\n  1966→| 0 | Home | link | URL: / |\n  1967→| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n  1968→| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n  1969→\n  1970→---\n  1971→\n  1972→### 3.17 AnalyticsSeeder\n  1973→\n  1974→**Acme Fashion - analytics_daily (30 days of daily data):**\n  1975→\n  1976→Generate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n  1977→\n  1978→**Generation algorithm (pseudo-code):**\n  1979→\n  1980→```\n  1981→FOR i FROM 30 DOWN TO 0:\n  1982→    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n  1983→    visits = ROUND(RANDOM(50, 100) * dayFactor)\n  1984→    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n  1985→    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n  1986→    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n  1987→    aov = RANDOM(4000, 9000)               // average order value in cents\n  1988→    revenue = orders * aov\n  1989→\n  1990→    INSERT analytics_daily:\n  1991→        store_id = Acme Fashion ID\n  1992→        date = TODAY minus i days (ISO format YYYY-MM-DD)\n  1993→        visits_count = visits\n  1994→        add_to_cart_count = addToCart\n  1995→        checkout_started_count = checkoutStarted\n  1996→        orders_count = orders\n  1997→        revenue_amount = revenue\n  1998→        aov_amount = aov\n  1999→```"}]},"uuid":"e2157b42-0c35-4baf-8a5d-53450ffc1993","timestamp":"2026-02-11T15:19:56.991Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","content":"| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```","numLines":800,"startLine":1200,"totalLines":2215}},"sourceToolAssistantUUID":"b3b945a5-8742-4961-abb0-da815307ba1d"}
{"parentUuid":"e2157b42-0c35-4baf-8a5d-53450ffc1993","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01A5yPgARX7TDhzykRopdsy6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01B9KrhffRm2TH48A7qLpEs1","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":330,"limit":500}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1190,"cache_read_input_tokens":74040,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1190},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WN6pHrqDRsL69Phiw3","type":"assistant","uuid":"8822d664-1463-4a07-877a-fa12ca151016","timestamp":"2026-02-11T15:19:57.602Z"}
{"parentUuid":"8822d664-1463-4a07-877a-fa12ca151016","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01B9KrhffRm2TH48A7qLpEs1","toolUseID":"toolu_01B9KrhffRm2TH48A7qLpEs1","timestamp":"2026-02-11T15:19:57.604Z","uuid":"2335f0bb-c2f2-47e5-ac84-caffff8cfed5"}
{"parentUuid":"8822d664-1463-4a07-877a-fa12ca151016","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01B9KrhffRm2TH48A7qLpEs1","type":"tool_result","content":"   330→    Dashboard --> Discounts: click \"Discounts\"\n   331→    Dashboard --> Settings: click \"Settings\"\n   332→    Dashboard --> Analytics: click \"Analytics\"\n   333→    Dashboard --> Themes: click \"Themes\"\n   334→    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n   335→```\n   336→\n   337→---\n   338→\n   339→### Test 2.1: Can log in as admin\n   340→\n   341→**Preconditions:** Seeded admin user\n   342→\n   343→**Steps:**\n   344→1. Navigate to `/admin/login`\n   345→2. Enter email: `admin@acme.test`\n   346→3. Enter password: `password`\n   347→4. Click the \"Sign in\" button\n   348→\n   349→**Expected Results:**\n   350→- Page displays \"Dashboard\"\n   351→- No JavaScript errors\n   352→\n   353→---\n   354→\n   355→### Test 2.2: Shows error for invalid credentials\n   356→\n   357→**Preconditions:** Seeded admin user\n   358→\n   359→**Steps:**\n   360→1. Navigate to `/admin/login`\n   361→2. Enter email: `admin@acme.test`\n   362→3. Enter password: `wrongpassword`\n   363→4. Click the \"Sign in\" button\n   364→\n   365→**Expected Results:**\n   366→- Page displays \"Invalid credentials\"\n   367→- No JavaScript errors\n   368→\n   369→---\n   370→\n   371→### Test 2.3: Shows error for empty email\n   372→\n   373→**Preconditions:** None\n   374→\n   375→**Steps:**\n   376→1. Navigate to `/admin/login`\n   377→2. Enter password: `password` (leave email empty)\n   378→3. Click the \"Sign in\" button\n   379→\n   380→**Expected Results:**\n   381→- Validation error referencing \"email\" is visible\n   382→- No JavaScript errors\n   383→\n   384→---\n   385→\n   386→### Test 2.4: Shows error for empty password\n   387→\n   388→**Preconditions:** None\n   389→\n   390→**Steps:**\n   391→1. Navigate to `/admin/login`\n   392→2. Enter email: `admin@acme.test` (leave password empty)\n   393→3. Click the \"Sign in\" button\n   394→\n   395→**Expected Results:**\n   396→- Validation error referencing \"password\" is visible\n   397→- No JavaScript errors\n   398→\n   399→---\n   400→\n   401→### Test 2.5: Redirects unauthenticated users to login from dashboard\n   402→\n   403→**Preconditions:** Not logged in\n   404→\n   405→**Steps:**\n   406→1. Navigate to `/admin` (without logging in)\n   407→\n   408→**Expected Results:**\n   409→- Page displays \"Sign in\" (redirected to login form)\n   410→- No JavaScript errors\n   411→\n   412→---\n   413→\n   414→### Test 2.6: Redirects unauthenticated users to login from products\n   415→\n   416→**Preconditions:** Not logged in\n   417→\n   418→**Steps:**\n   419→1. Navigate to `/admin/products` (without logging in)\n   420→\n   421→**Expected Results:**\n   422→- Page displays \"Sign in\" (redirected to login form)\n   423→- No JavaScript errors\n   424→\n   425→---\n   426→\n   427→### Test 2.7: Can log out\n   428→\n   429→**Preconditions:** Seeded admin user\n   430→\n   431→**Steps:**\n   432→1. Navigate to `/admin/login`\n   433→2. Enter email: `admin@acme.test`\n   434→3. Enter password: `password`\n   435→4. Click the \"Sign in\" button\n   436→5. Verify \"Dashboard\" is visible\n   437→6. Click on \"Admin User\" (profile/user menu)\n   438→7. Click \"Logout\"\n   439→\n   440→**Expected Results:**\n   441→- Page displays \"Sign in\" (returned to login form)\n   442→\n   443→---\n   444→\n   445→### Test 2.8: Can navigate through admin sidebar sections\n   446→\n   447→**Preconditions:** Seeded admin user\n   448→\n   449→**Steps:**\n   450→1. Navigate to `/admin/login`\n   451→2. Enter email: `admin@acme.test`\n   452→3. Enter password: `password`\n   453→4. Click the \"Sign in\" button\n   454→5. Click \"Products\" in the sidebar\n   455→6. Verify \"Products\" heading is visible\n   456→7. Click \"Orders\" in the sidebar\n   457→8. Verify \"Orders\" heading is visible\n   458→9. Click \"Customers\" in the sidebar\n   459→10. Verify \"Customers\" heading is visible\n   460→11. Click \"Discounts\" in the sidebar\n   461→12. Verify \"Discounts\" heading is visible\n   462→13. Click \"Settings\" in the sidebar\n   463→14. Verify \"Store Settings\" heading is visible\n   464→\n   465→**Expected Results:**\n   466→- Each section loads without JavaScript errors\n   467→- Each section heading is visible after clicking its sidebar link\n   468→\n   469→---\n   470→\n   471→### Test 2.9: Can navigate to analytics from sidebar\n   472→\n   473→**Preconditions:** Seeded admin user\n   474→\n   475→**Steps:**\n   476→1. Navigate to `/admin/login`\n   477→2. Enter email: `admin@acme.test`\n   478→3. Enter password: `password`\n   479→4. Click the \"Sign in\" button\n   480→5. Click \"Analytics\" in the sidebar\n   481→\n   482→**Expected Results:**\n   483→- Page displays \"Analytics\"\n   484→- No JavaScript errors\n   485→\n   486→---\n   487→\n   488→### Test 2.10: Can navigate to themes from sidebar\n   489→\n   490→**Preconditions:** Seeded admin user\n   491→\n   492→**Steps:**\n   493→1. Navigate to `/admin/login`\n   494→2. Enter email: `admin@acme.test`\n   495→3. Enter password: `password`\n   496→4. Click the \"Sign in\" button\n   497→5. Click \"Themes\" in the sidebar\n   498→\n   499→**Expected Results:**\n   500→- Page displays \"Themes\"\n   501→- No JavaScript errors\n   502→\n   503→**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n   504→\n   505→**Total: 10 tests**\n   506→\n   507→---\n   508→\n   509→## Suite 3: Admin Product Management\n   510→\n   511→**File:** `tests/Browser/Admin/ProductManagementTest.php`\n   512→\n   513→**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n   514→\n   515→### Product Lifecycle States\n   516→\n   517→```mermaid\n   518→stateDiagram-v2\n   519→    [*] --> Draft: create product (default)\n   520→    Draft --> Active: set status to Active + Save\n   521→    Active --> Archived: set status to Archived + Save\n   522→    Draft --> Archived: set status to Archived + Save\n   523→    Archived --> Active: set status to Active + Save\n   524→    Active --> Draft: set status to Draft + Save\n   525→```\n   526→\n   527→---\n   528→\n   529→### Test 3.1: Shows the product list with seeded products\n   530→\n   531→**Preconditions:** Logged in as admin, seeded products\n   532→\n   533→**Steps:**\n   534→1. Navigate to `/admin/login`\n   535→2. Enter email: `admin@acme.test`\n   536→3. Enter password: `password`\n   537→4. Click the \"Sign in\" button\n   538→5. Click \"Products\" in the sidebar\n   539→\n   540→**Expected Results:**\n   541→- Page displays \"Classic Cotton T-Shirt\"\n   542→- Page displays \"Premium Slim Fit Jeans\"\n   543→- No JavaScript errors\n   544→\n   545→---\n   546→\n   547→### Test 3.2: Can create a new product\n   548→\n   549→**Preconditions:** Logged in as admin\n   550→\n   551→**Steps:**\n   552→1. Navigate to `/admin/login`\n   553→2. Log in as admin (email: `admin@acme.test`, password: `password`)\n   554→3. Click \"Products\" in the sidebar\n   555→4. Click \"Add product\"\n   556→5. Enter title: `Test Product Created by E2E`\n   557→6. Enter description: `This product was created by the E2E test suite.`\n   558→7. Enter vendor: `Test Vendor`\n   559→8. Enter product type: `T-Shirts`\n   560→9. Enter variant price (variants.0.price): `29.99`\n   561→10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n   562→11. Enter variant quantity (variants.0.quantity): `50`\n   563→12. Click \"Save\"\n   564→13. Click \"Products\" to return to the product list\n   565→\n   566→**Expected Results:**\n   567→- After step 12: page displays \"Product saved\", no JavaScript errors\n   568→- After step 13: page displays \"Test Product Created by E2E\" in the list\n   569→\n   570→---\n   571→\n   572→### Test 3.3: Can edit an existing product title\n   573→\n   574→**Preconditions:** Logged in as admin, seeded products\n   575→\n   576→**Steps:**\n   577→1. Navigate to `/admin/login`\n   578→2. Log in as admin\n   579→3. Click \"Products\" in the sidebar\n   580→4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n   581→5. Click \"Classic Cotton T-Shirt\" to open the edit form\n   582→6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n   583→7. Click \"Save\"\n   584→8. Click \"Products\" to return to the product list\n   585→\n   586→**Expected Results:**\n   587→- After step 7: page displays \"Product saved\", no JavaScript errors\n   588→- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n   589→\n   590→---\n   591→\n   592→### Test 3.4: Can archive a product\n   593→\n   594→**Preconditions:** Logged in as admin\n   595→\n   596→**Steps:**\n   597→1. Navigate to `/admin/login`\n   598→2. Log in as admin\n   599→3. Click \"Products\" in the sidebar\n   600→4. Click \"Add product\"\n   601→5. Enter title: `Product To Archive`\n   602→6. Enter variant price: `19.99`\n   603→7. Enter variant SKU: `E2E-ARCHIVE-001`\n   604→8. Enter variant quantity: `10`\n   605→9. Click \"Save\"\n   606→10. Verify \"Product saved\" is displayed\n   607→11. Click \"Products\" to return to the product list\n   608→12. Click \"Product To Archive\" to open the edit form\n   609→13. Change status to \"Archived\" (click the Archived option)\n   610→14. Click \"Save\"\n   611→15. Click \"Products\" to return to the product list\n   612→\n   613→**Expected Results:**\n   614→- After step 9: page displays \"Product saved\"\n   615→- After step 14: page displays \"Product saved\"\n   616→- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n   617→\n   618→---\n   619→\n   620→### Test 3.5: Shows draft products only in admin, not storefront\n   621→\n   622→**Preconditions:** Logged in as admin, Product #15 exists with draft status\n   623→\n   624→**Steps:**\n   625→1. Navigate to `/admin/login`\n   626→2. Log in as admin\n   627→3. Click \"Products\" in the sidebar\n   628→4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n   629→5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n   630→6. Verify the draft product does NOT appear in the collection listing\n   631→7. Navigate to `/search?q=draft`\n   632→8. Verify the draft product does NOT appear in search results\n   633→\n   634→**Expected Results:**\n   635→- Step 4: draft product visible in admin, no JavaScript errors\n   636→- Step 6: draft product absent from storefront collection, no JavaScript errors\n   637→- Step 8: draft product absent from search results, no JavaScript errors\n   638→\n   639→---\n   640→\n   641→### Test 3.6: Can search products in admin\n   642→\n   643→**Preconditions:** Logged in as admin, seeded products\n   644→\n   645→**Steps:**\n   646→1. Navigate to `/admin/login`\n   647→2. Log in as admin\n   648→3. Click \"Products\" in the sidebar\n   649→4. Enter \"Cotton\" in the search input field\n   650→\n   651→**Expected Results:**\n   652→- Page displays \"Classic Cotton T-Shirt\" in filtered results\n   653→- No JavaScript errors\n   654→\n   655→---\n   656→\n   657→### Test 3.7: Can filter products by status in admin\n   658→\n   659→**Preconditions:** Logged in as admin, seeded products including drafts\n   660→\n   661→**Steps:**\n   662→1. Navigate to `/admin/login`\n   663→2. Log in as admin\n   664→3. Click \"Products\" in the sidebar\n   665→4. Click the \"Draft\" status filter tab\n   666→5. Verify only draft products are visible\n   667→6. Click the \"Active\" status filter tab\n   668→\n   669→**Expected Results:**\n   670→- After step 4: only draft products are visible, no JavaScript errors\n   671→- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n   672→\n   673→**Total: 7 tests**\n   674→\n   675→---\n   676→\n   677→## Suite 4: Admin Order Management\n   678→\n   679→**File:** `tests/Browser/Admin/OrderManagementTest.php`\n   680→\n   681→**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n   682→\n   683→### Order State Transitions (via admin actions)\n   684→\n   685→```mermaid\n   686→stateDiagram-v2\n   687→    state financial {\n   688→        pending_f --> paid: payment captured\n   689→        paid --> partially_refunded: partial refund\n   690→        paid --> refunded: full refund\n   691→    }\n   692→    state fulfillment {\n   693→        unfulfilled --> partial: partial fulfillment\n   694→        unfulfilled --> fulfilled: full fulfillment\n   695→        partial --> fulfilled: remaining items fulfilled\n   696→    }\n   697→```\n   698→\n   699→---\n   700→\n   701→### Test 4.1: Shows the order list with seeded orders\n   702→\n   703→**Preconditions:** Logged in as admin, seeded orders\n   704→\n   705→**Steps:**\n   706→1. Navigate to `/admin/login`\n   707→2. Log in as admin\n   708→3. Click \"Orders\" in the sidebar\n   709→\n   710→**Expected Results:**\n   711→- Page displays \"#1001\"\n   712→- No JavaScript errors\n   713→\n   714→---\n   715→\n   716→### Test 4.2: Can filter orders by status\n   717→\n   718→**Preconditions:** Logged in as admin, seeded orders\n   719→\n   720→**Steps:**\n   721→1. Navigate to `/admin/login`\n   722→2. Log in as admin\n   723→3. Click \"Orders\" in the sidebar\n   724→4. Click the \"Paid\" status filter/tab\n   725→5. Verify \"#1001\" is visible\n   726→6. Click the \"Fulfilled\" status filter/tab\n   727→7. Click the \"All\" tab to reset the filter\n   728→\n   729→**Expected Results:**\n   730→- After step 4: \"#1001\" is visible, no JavaScript errors\n   731→- After step 6: filtered results shown, no JavaScript errors\n   732→- After step 7: full list shown, no JavaScript errors\n   733→\n   734→---\n   735→\n   736→### Test 4.3: Shows order detail with line items and totals\n   737→\n   738→**Preconditions:** Logged in as admin, Order #1001 exists\n   739→\n   740→**Steps:**\n   741→1. Navigate to `/admin/login`\n   742→2. Log in as admin\n   743→3. Click \"Orders\" in the sidebar\n   744→4. Click \"#1001\" to open the order detail\n   745→\n   746→**Expected Results:**\n   747→- Page displays \"#1001\"\n   748→- Page displays \"Paid\" (financial status)\n   749→- Page displays \"Unfulfilled\" (fulfillment status)\n   750→- Page displays \"Classic Cotton T-Shirt\" (line item)\n   751→- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n   752→- No JavaScript errors\n   753→\n   754→---\n   755→\n   756→### Test 4.4: Shows order timeline events\n   757→\n   758→**Preconditions:** Logged in as admin, Order #1001 exists\n   759→\n   760→**Steps:**\n   761→1. Navigate to `/admin/login`\n   762→2. Log in as admin\n   763→3. Click \"Orders\" in the sidebar\n   764→4. Click \"#1001\" to open the order detail\n   765→\n   766→**Expected Results:**\n   767→- Page displays \"Timeline\" section\n   768→- Timeline shows at least the order creation event\n   769→- No JavaScript errors\n   770→\n   771→---\n   772→\n   773→### Test 4.5: Can create a fulfillment\n   774→\n   775→**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n   776→\n   777→**Steps:**\n   778→1. Navigate to `/admin/login`\n   779→2. Log in as admin\n   780→3. Click \"Orders\" in the sidebar\n   781→4. Click \"#1001\" to open the order detail\n   782→5. Click \"Create fulfillment\"\n   783→6. Enter tracking company: `DHL`\n   784→7. Enter tracking number: `DHL123456789`\n   785→8. Click \"Fulfill items\"\n   786→\n   787→**Expected Results:**\n   788→- Page displays \"Fulfillment created\"\n   789→- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n   790→- No JavaScript errors\n   791→\n   792→---\n   793→\n   794→### Test 4.6: Can process a refund\n   795→\n   796→**Preconditions:** Logged in as admin, Order #1001 is paid\n   797→\n   798→**Steps:**\n   799→1. Navigate to `/admin/login`\n   800→2. Log in as admin\n   801→3. Click \"Orders\" in the sidebar\n   802→4. Click \"#1001\" to open the order detail\n   803→5. Click \"Refund\"\n   804→6. Enter amount: `10.00`\n   805→7. Enter reason: `Customer requested partial refund`\n   806→8. Click \"Process refund\"\n   807→\n   808→**Expected Results:**\n   809→- Page displays \"Refund processed\"\n   810→- Page displays \"Partially refunded\" (updated financial status)\n   811→- No JavaScript errors\n   812→\n   813→---\n   814→\n   815→### Test 4.7: Shows customer information in order detail\n   816→\n   817→**Preconditions:** Logged in as admin, Order #1001 exists with customer\n   818→\n   819→**Steps:**\n   820→1. Navigate to `/admin/login`\n   821→2. Log in as admin\n   822→3. Click \"Orders\" in the sidebar\n   823→4. Click \"#1001\" to open the order detail\n   824→\n   825→**Expected Results:**\n   826→- Page displays \"customer@acme.test\" in the customer info section\n   827→- No JavaScript errors\n   828→\n   829→**Total: 7 tests**"}]},"uuid":"25cbb437-2e3e-41bd-aca5-de7ee1bce5d0","timestamp":"2026-02-11T15:19:57.604Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n**Total: 7 tests**","numLines":500,"startLine":330,"totalLines":2977}},"sourceToolAssistantUUID":"8822d664-1463-4a07-877a-fa12ca151016"}
{"parentUuid":"25cbb437-2e3e-41bd-aca5-de7ee1bce5d0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01A5yPgARX7TDhzykRopdsy6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012Ee8fePELKhsmQFhhF6C9A","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":580,"limit":200}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1190,"cache_read_input_tokens":74040,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1190},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WN6pHrqDRsL69Phiw3","type":"assistant","uuid":"60f08a46-234e-4de9-b240-fd43198c0554","timestamp":"2026-02-11T15:19:58.330Z"}
{"parentUuid":"60f08a46-234e-4de9-b240-fd43198c0554","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_012Ee8fePELKhsmQFhhF6C9A","toolUseID":"toolu_012Ee8fePELKhsmQFhhF6C9A","timestamp":"2026-02-11T15:19:58.335Z","uuid":"f68ac904-74ef-4536-9a57-dcfbaa89bdc8"}
{"parentUuid":"60f08a46-234e-4de9-b240-fd43198c0554","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012Ee8fePELKhsmQFhhF6C9A","type":"tool_result","content":"   580→\n   581→| Type | Name | Purpose |\n   582→|------|------|---------|\n   583→| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n   584→| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n   585→\n   586→#### Step 4.9: Storefront Cart/Checkout UI\n   587→\n   588→**Files to create:**\n   589→\n   590→| Component | Description |\n   591→|-----------|-------------|\n   592→| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n   593→| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n   594→| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n   595→| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n   596→\n   597→---\n   598→\n   599→### Phase 5: Payments, Orders, Fulfillment\n   600→\n   601→**Priority:** HIGH - completes the purchase flow.\n   602→\n   603→#### Step 5.1: Migrations (Batch 5-7)\n   604→\n   605→1. `create_customers_table` - FK: `store_id`\n   606→2. `create_customer_addresses_table` - FK: `customer_id`\n   607→3. `create_orders_table` - FK: `store_id`, `customer_id`\n   608→4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n   609→5. `create_payments_table` - FK: `order_id`\n   610→6. `create_refunds_table` - FK: `order_id`, `payment_id`\n   611→7. `create_fulfillments_table` - FK: `order_id`\n   612→8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n   613→\n   614→#### Step 5.2: Models\n   615→\n   616→**Files to create:**\n   617→\n   618→| Type | Name | Purpose |\n   619→|------|------|---------|\n   620→| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n   621→| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n   622→| Model (with migration, factory, seeder) | `Order` | Order entity |\n   623→| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n   624→| Model (with migration, factory, seeder) | `Payment` | Payment record |\n   625→| Model (with migration, factory, seeder) | `Refund` | Refund record |\n   626→| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n   627→| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n   628→\n   629→**Relationship definitions:**\n   630→\n   631→| Model | Key Relationships |\n   632→|-------|-------------------|\n   633→| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n   634→| `CustomerAddress` | `belongsTo(Customer)` |\n   635→| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n   636→| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n   637→| `Payment` | `belongsTo(Order)` |\n   638→| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n   639→| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n   640→| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n   641→\n   642→**Enums to create:**\n   643→\n   644→| Enum | Values |\n   645→|------|--------|\n   646→| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n   647→| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n   648→| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n   649→| `PaymentProvider` | `Stripe`, `Paypal` |\n   650→| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |\n   651→| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n   652→| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n   653→\n   654→#### Step 5.3: Payment Service\n   655→\n   656→**Files to create:**\n   657→\n   658→| Type | Name | Purpose |\n   659→|------|------|---------|\n   660→| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n   661→| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n   662→| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n   663→\n   664→**PaymentProvider interface methods:**\n   665→\n   666→| Method | Description |\n   667→|--------|-------------|\n   668→| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n   669→| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n   670→| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n   671→\n   672→**StripePaymentProvider requirements:**\n   673→- Creates Stripe Checkout Sessions in test mode\n   674→- Webhook handler with HMAC signature verification\n   675→- Idempotent event processing (checks `provider_payment_id` uniqueness)\n   676→\n   677→**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.\n   678→\n   679→#### Step 5.4: Order Service\n   680→\n   681→**File to create:** `App\\Services\\OrderService`\n   682→\n   683→| Method | Description |\n   684→|--------|-------------|\n   685→| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n   686→| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n   687→| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n   688→\n   689→Order lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n   690→\n   691→#### Step 5.5: Refund Service\n   692→\n   693→**File to create:** `App\\Services\\RefundService`\n   694→\n   695→| Method | Description |\n   696→|--------|-------------|\n   697→| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n   698→\n   699→- Partial refunds: `financial_status` -> `partially_refunded`\n   700→- Full refunds: `financial_status` -> `refunded`\n   701→- Restock flag: calls `InventoryService::restock()` for each line\n   702→\n   703→#### Step 5.6: Fulfillment Service\n   704→\n   705→**File to create:** `App\\Services\\FulfillmentService`\n   706→\n   707→| Method | Description |\n   708→|--------|-------------|\n   709→| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n   710→| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n   711→| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n   712→\n   713→Updates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n   714→\n   715→**Events to create:**\n   716→\n   717→| Type | Name | Purpose |\n   718→|------|------|---------|\n   719→| Event | `OrderCreated` | Dispatched when order is created from checkout |\n   720→| Event | `OrderPaid` | Dispatched when payment is captured |\n   721→| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n   722→| Event | `OrderCancelled` | Dispatched when order is cancelled |\n   723→| Event | `OrderRefunded` | Dispatched when refund is processed |\n   724→\n   725→---\n   726→\n   727→### Phase 6: Customer Accounts\n   728→\n   729→**Priority:** MEDIUM - enhances the shopping experience.\n   730→\n   731→#### Step 6.1: Customer Auth\n   732→\n   733→- `Customer` model with custom `customer` guard (already configured in Phase 1)\n   734→- Custom `CustomerUserProvider` - scopes queries by `store_id`\n   735→- Customer email unique per store, not globally\n   736→\n   737→#### Step 6.2: Customer Account Pages (Livewire)\n   738→\n   739→**Files to create:**\n   740→\n   741→| Component | Route | Description |\n   742→|-----------|-------|-------------|\n   743→| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n   744→| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n   745→| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n   746→| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n   747→| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n   748→| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n   749→\n   750→Logout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n   751→\n   752→---\n   753→\n   754→### Phase 7: Admin Panel\n   755→\n   756→**Priority:** MEDIUM - merchant management interface.\n   757→\n   758→#### Step 7.1: Admin Layout\n   759→\n   760→**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n   761→\n   762→**Components to create:**\n   763→\n   764→| Component | Description |\n   765→|-----------|-------------|\n   766→| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n   767→| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n   768→| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n   769→\n   770→Toast notifications via Livewire events.\n   771→\n   772→#### Step 7.2: Dashboard\n   773→\n   774→**File to create:** `Admin\\Dashboard` Livewire component\n   775→\n   776→Requirements:\n   777→- KPI tiles: total sales (period), order count, average order value, conversion rate\n   778→- Orders chart (last 30 days line chart)\n   779→- Recent orders table (last 10)"}]},"uuid":"78ec4728-56cf-4566-9269-b3276b0e926a","timestamp":"2026-02-11T15:19:58.334Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentProvider` | `Stripe`, `Paypal` |\n| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n\n**StripePaymentProvider requirements:**\n- Creates Stripe Checkout Sessions in test mode\n- Webhook handler with HMAC signature verification\n- Idempotent event processing (checks `provider_payment_id` uniqueness)\n\n**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)","numLines":200,"startLine":580,"totalLines":2027}},"sourceToolAssistantUUID":"60f08a46-234e-4de9-b240-fd43198c0554"}
{"parentUuid":"78ec4728-56cf-4566-9269-b3276b0e926a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018MqV9pxUfPMMGQHtijvGbk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a good picture of all the sections that need updating. Let me also check the checkout flow tests (Suite 9) in the E2E plan.","signature":"Eq8CCkYICxgCKkBOtmSwWJ2hp6fdkRj8iZTHQN3sJ4Z+RsniWsfnJgRM2H5+LPRMSF+earobGww/71T8ILyS0eIKVurYtX6ptD0oEgwPPYGjQ7SOIsyycWcaDCrsRtuZS1zdCuEZcyIwQtn4SCOT1jr5W8KuIvnOGQ9Htdj++kyvAi2fn6k1Gh7jUrXoRr+iFCOTmsieLTtqKpYB++e+a8dmrB1orUmsa8ualMxmFp9NOtXoXEckzj8+5ZGYqVP7g28QD667NvGafBFMcUqqSc9m78Lg/78vxgCV99fGadCGXnmhAnW46q0aCZOGyEWmEIw5MpcUD2gq/H/NYryGC3r3gkpmz7K2xBOGjB8pLa4w9l8RO3LFgEDBn5/RgKcGbuJQpTbeD31GX2Bl491JyGE2GAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":23910,"cache_read_input_tokens":75230,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":23910},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WNcCdChwVBTzbBHcMs","type":"assistant","uuid":"beca8eab-1528-44d5-9cb7-e38af8729d44","timestamp":"2026-02-11T15:20:03.207Z"}
{"parentUuid":"beca8eab-1528-44d5-9cb7-e38af8729d44","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018MqV9pxUfPMMGQHtijvGbk","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the checkout test suite and the 09-IMPLEMENTATION-ROADMAP technology decisions section."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":23910,"cache_read_input_tokens":75230,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":23910},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WNcCdChwVBTzbBHcMs","type":"assistant","uuid":"a7b8f513-5479-4fd8-bf5f-263955fa1155","timestamp":"2026-02-11T15:20:04.034Z"}
{"parentUuid":"a7b8f513-5479-4fd8-bf5f-263955fa1155","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018MqV9pxUfPMMGQHtijvGbk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018TzZG8DtV9M1vgzpRWB6G4","name":"Grep","input":{"pattern":"Suite 9|Test 9\\.|checkout.*flow|Suite 4.*Order|Test 4\\.[5-9]","path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","output_mode":"content"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":23910,"cache_read_input_tokens":75230,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":23910},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WNcCdChwVBTzbBHcMs","type":"assistant","uuid":"5a0cf5cc-a0a9-4f1d-a563-dee21d93c676","timestamp":"2026-02-11T15:20:05.740Z"}
{"parentUuid":"5a0cf5cc-a0a9-4f1d-a563-dee21d93c676","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_018TzZG8DtV9M1vgzpRWB6G4","toolUseID":"toolu_018TzZG8DtV9M1vgzpRWB6G4","timestamp":"2026-02-11T15:20:06.020Z","uuid":"5c232f13-9e91-485e-a404-352b644eb164"}
{"parentUuid":"5a0cf5cc-a0a9-4f1d-a563-dee21d93c676","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018TzZG8DtV9M1vgzpRWB6G4","type":"tool_result","content":"118:    S2 --> S4[Suite 4: Admin Orders]\n126:    S8 --> S9[Suite 9: Checkout Flow]\n140:| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n677:## Suite 4: Admin Order Management\n773:### Test 4.5: Can create a fulfillment\n794:### Test 4.6: Can process a refund\n815:### Test 4.7: Shows customer information in order detail\n1618:## Suite 9: Checkout Flow\n1676:### Test 9.1: Completes full checkout happy path\n1709:### Test 9.2: Shows shipping methods based on German address\n1737:### Test 9.3: Shows international shipping methods for non-DE address\n1764:### Test 9.4: Applies discount during checkout\n1798:### Test 9.5: Validates required contact email\n1817:### Test 9.6: Validates required shipping address fields\n1838:### Test 9.7: Validates invalid postal code format\n1865:### Test 9.8: Prevents checkout with empty cart\n2956:| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |"}]},"uuid":"299944e1-cb26-4c1d-a487-ab767b12ec38","timestamp":"2026-02-11T15:20:06.019Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"118:    S2 --> S4[Suite 4: Admin Orders]\n126:    S8 --> S9[Suite 9: Checkout Flow]\n140:| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n677:## Suite 4: Admin Order Management\n773:### Test 4.5: Can create a fulfillment\n794:### Test 4.6: Can process a refund\n815:### Test 4.7: Shows customer information in order detail\n1618:## Suite 9: Checkout Flow\n1676:### Test 9.1: Completes full checkout happy path\n1709:### Test 9.2: Shows shipping methods based on German address\n1737:### Test 9.3: Shows international shipping methods for non-DE address\n1764:### Test 9.4: Applies discount during checkout\n1798:### Test 9.5: Validates required contact email\n1817:### Test 9.6: Validates required shipping address fields\n1838:### Test 9.7: Validates invalid postal code format\n1865:### Test 9.8: Prevents checkout with empty cart\n2956:| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |","numLines":17},"sourceToolAssistantUUID":"5a0cf5cc-a0a9-4f1d-a563-dee21d93c676"}
{"parentUuid":"299944e1-cb26-4c1d-a487-ab767b12ec38","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018MqV9pxUfPMMGQHtijvGbk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GS7us8FVy92vJ7ghvTtdBf","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":829,"limit":500}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":23910,"cache_read_input_tokens":75230,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":23910},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WNcCdChwVBTzbBHcMs","type":"assistant","uuid":"c00cf714-cd41-49bc-8c98-230664b9db44","timestamp":"2026-02-11T15:20:06.811Z"}
{"parentUuid":"c00cf714-cd41-49bc-8c98-230664b9db44","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01GS7us8FVy92vJ7ghvTtdBf","toolUseID":"toolu_01GS7us8FVy92vJ7ghvTtdBf","timestamp":"2026-02-11T15:20:06.815Z","uuid":"716b84d2-c0d1-4809-8e72-d76523a5a288"}
{"parentUuid":"c00cf714-cd41-49bc-8c98-230664b9db44","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GS7us8FVy92vJ7ghvTtdBf","type":"tool_result","content":"   829→**Total: 7 tests**\n   830→\n   831→---\n   832→\n   833→## Suite 5: Admin Discount Management\n   834→\n   835→**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n   836→\n   837→**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n   838→\n   839→### Discount Types\n   840→\n   841→| Value Type | Description | Fields Required |\n   842→|------------|-------------|-----------------|\n   843→| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n   844→| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n   845→| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n   846→\n   847→---\n   848→\n   849→### Test 5.1: Shows seeded discount codes\n   850→\n   851→**Preconditions:** Logged in as admin, seeded discounts\n   852→\n   853→**Steps:**\n   854→1. Navigate to `/admin/login`\n   855→2. Log in as admin\n   856→3. Click \"Discounts\" in the sidebar\n   857→\n   858→**Expected Results:**\n   859→- Page displays \"WELCOME10\"\n   860→- Page displays \"FLAT5\"\n   861→- Page displays \"FREESHIP\"\n   862→- No JavaScript errors\n   863→\n   864→---\n   865→\n   866→### Test 5.2: Can create a new percentage discount code\n   867→\n   868→**Preconditions:** Logged in as admin\n   869→\n   870→**Steps:**\n   871→1. Navigate to `/admin/login`\n   872→2. Log in as admin\n   873→3. Click \"Discounts\" in the sidebar\n   874→4. Click \"Create discount\"\n   875→5. Enter code: `E2ETEST25`\n   876→6. Click \"Percentage\" to select the value type\n   877→7. Enter value: `25`\n   878→8. Enter starts_at: `2026-01-01`\n   879→9. Enter ends_at: `2026-12-31`\n   880→10. Click \"Save\"\n   881→11. Click \"Discounts\" to return to the list\n   882→\n   883→**Expected Results:**\n   884→- After step 10: page displays \"Discount saved\", no JavaScript errors\n   885→- After step 11: page displays \"E2ETEST25\" in the list\n   886→\n   887→---\n   888→\n   889→### Test 5.3: Can create a fixed amount discount code\n   890→\n   891→**Preconditions:** Logged in as admin\n   892→\n   893→**Steps:**\n   894→1. Navigate to `/admin/login`\n   895→2. Log in as admin\n   896→3. Click \"Discounts\" then \"Create discount\"\n   897→4. Enter code: `E2EFLAT10`\n   898→5. Click \"Fixed amount\" to select the value type\n   899→6. Enter value: `10.00`\n   900→7. Enter starts_at: `2026-01-01`\n   901→8. Click \"Save\"\n   902→\n   903→**Expected Results:**\n   904→- Page displays \"Discount saved\"\n   905→- No JavaScript errors\n   906→\n   907→---\n   908→\n   909→### Test 5.4: Can create a free shipping discount code\n   910→\n   911→**Preconditions:** Logged in as admin\n   912→\n   913→**Steps:**\n   914→1. Navigate to `/admin/login`\n   915→2. Log in as admin\n   916→3. Click \"Discounts\" then \"Create discount\"\n   917→4. Enter code: `E2EFREESHIP`\n   918→5. Click \"Free shipping\" to select the value type\n   919→6. Enter starts_at: `2026-01-01`\n   920→7. Click \"Save\"\n   921→\n   922→**Expected Results:**\n   923→- Page displays \"Discount saved\"\n   924→- No JavaScript errors\n   925→\n   926→---\n   927→\n   928→### Test 5.5: Can edit a discount\n   929→\n   930→**Preconditions:** Logged in as admin, WELCOME10 discount exists\n   931→\n   932→**Steps:**\n   933→1. Navigate to `/admin/login`\n   934→2. Log in as admin\n   935→3. Click \"Discounts\" in the sidebar\n   936→4. Click \"WELCOME10\" to open the edit form\n   937→5. Change the value field to: `15`\n   938→6. Click \"Save\"\n   939→\n   940→**Expected Results:**\n   941→- Page displays \"Discount saved\"\n   942→- No JavaScript errors\n   943→\n   944→---\n   945→\n   946→### Test 5.6: Shows discount status indicators\n   947→\n   948→**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n   949→\n   950→**Steps:**\n   951→1. Navigate to `/admin/login`\n   952→2. Log in as admin\n   953→3. Click \"Discounts\" in the sidebar\n   954→\n   955→**Expected Results:**\n   956→- Page displays \"Active\" status badge (for active discounts)\n   957→- Page displays \"Expired\" status badge (for EXPIRED20)\n   958→- No JavaScript errors\n   959→\n   960→**Total: 6 tests**\n   961→\n   962→---\n   963→\n   964→## Suite 6: Admin Settings\n   965→\n   966→**File:** `tests/Browser/Admin/SettingsTest.php`\n   967→\n   968→**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n   969→\n   970→### Settings Page Structure\n   971→\n   972→The admin settings page at `/admin/settings` uses a tabbed interface:\n   973→\n   974→| Tab | Route | Content |\n   975→|-----|-------|---------|\n   976→| General | `/admin/settings` | Store name, contact email, currency, locale |\n   977→| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n   978→| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n   979→| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n   980→| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n   981→| Notifications | `/admin/settings/notifications` | Email notification preferences |\n   982→\n   983→> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n   984→\n   985→---\n   986→\n   987→### Test 6.1: Can view store settings\n   988→\n   989→**Preconditions:** Logged in as admin, Store 1 exists\n   990→\n   991→**Steps:**\n   992→1. Navigate to `/admin/login`\n   993→2. Log in as admin\n   994→3. Click \"Settings\" in the sidebar\n   995→\n   996→**Expected Results:**\n   997→- Page displays \"Store Settings\"\n   998→- Page displays \"Acme Fashion\"\n   999→- No JavaScript errors\n  1000→\n  1001→---\n  1002→\n  1003→### Test 6.2: Can update store name\n  1004→\n  1005→**Preconditions:** Logged in as admin\n  1006→\n  1007→**Steps:**\n  1008→1. Navigate to `/admin/login`\n  1009→2. Log in as admin\n  1010→3. Click \"Settings\" in the sidebar\n  1011→4. Update the name field to: `Acme Fashion Updated`\n  1012→5. Click \"Save\"\n  1013→6. Click \"Settings\" again to reload the page\n  1014→\n  1015→**Expected Results:**\n  1016→- After step 5: page displays \"Settings saved\", no JavaScript errors\n  1017→- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n  1018→\n  1019→---\n  1020→\n  1021→### Test 6.3: Can view shipping zones\n  1022→\n  1023→**Preconditions:** Logged in as admin, seeded shipping zones\n  1024→\n  1025→**Steps:**\n  1026→1. Navigate to `/admin/login`\n  1027→2. Log in as admin\n  1028→3. Click \"Settings\" in the sidebar\n  1029→4. Click \"Shipping\" tab\n  1030→\n  1031→**Expected Results:**\n  1032→- Page displays \"Domestic\"\n  1033→- Page displays \"Standard Shipping\"\n  1034→- Page displays \"4.99\"\n  1035→- No JavaScript errors\n  1036→\n  1037→---\n  1038→\n  1039→### Test 6.4: Can add a new shipping rate to existing zone\n  1040→\n  1041→**Preconditions:** Logged in as admin, Domestic shipping zone exists\n  1042→\n  1043→**Steps:**\n  1044→1. Navigate to `/admin/login`\n  1045→2. Log in as admin\n  1046→3. Click \"Settings\" in the sidebar\n  1047→4. Click \"Shipping\" tab\n  1048→5. Click \"Add rate\" within the Domestic zone section\n  1049→6. Enter name: `Overnight Shipping`\n  1050→7. Enter price: `14.99`\n  1051→8. Click \"Save\"\n  1052→\n  1053→**Expected Results:**\n  1054→- Page displays \"Shipping rate saved\"\n  1055→- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n  1056→- No JavaScript errors\n  1057→\n  1058→---\n  1059→\n  1060→### Test 6.5: Can view tax settings\n  1061→\n  1062→**Preconditions:** Logged in as admin\n  1063→\n  1064→**Steps:**\n  1065→1. Navigate to `/admin/login`\n  1066→2. Log in as admin\n  1067→3. Click \"Settings\" in the sidebar\n  1068→4. Click \"Taxes\" tab\n  1069→\n  1070→**Expected Results:**\n  1071→- Page displays \"Tax Settings\"\n  1072→- No JavaScript errors\n  1073→\n  1074→---\n  1075→\n  1076→### Test 6.6: Can update tax inclusion setting\n  1077→\n  1078→**Preconditions:** Logged in as admin\n  1079→\n  1080→**Steps:**\n  1081→1. Navigate to `/admin/login`\n  1082→2. Log in as admin\n  1083→3. Click \"Settings\" in the sidebar\n  1084→4. Click \"Taxes\" tab\n  1085→5. Click/toggle the \"Prices include tax\" switch\n  1086→6. Click \"Save\"\n  1087→\n  1088→**Expected Results:**\n  1089→- Page displays \"Tax settings saved\"\n  1090→- No JavaScript errors\n  1091→\n  1092→---\n  1093→\n  1094→### Test 6.7: Can view domain settings\n  1095→\n  1096→**Preconditions:** Logged in as admin, Store 1 with domain configured\n  1097→\n  1098→**Steps:**\n  1099→1. Navigate to `/admin/login`\n  1100→2. Log in as admin\n  1101→3. Click \"Settings\" in the sidebar\n  1102→4. Click \"Domains\" tab\n  1103→\n  1104→**Expected Results:**\n  1105→- Page displays \"acme-fashion.test\"\n  1106→- No JavaScript errors\n  1107→\n  1108→**Total: 7 tests**\n  1109→\n  1110→---\n  1111→\n  1112→## Suite 7: Storefront Browsing\n  1113→\n  1114→**File:** `tests/Browser/Storefront/BrowsingTest.php`\n  1115→\n  1116→**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n  1117→\n  1118→### Storefront Page Map\n  1119→\n  1120→```mermaid\n  1121→flowchart LR\n  1122→    Home[\"/\"] --> Collections[\"/collections\"]\n  1123→    Home --> Product[\"/products/{handle}\"]\n  1124→    Collections --> CollectionDetail[\"/collections/{handle}\"]\n  1125→    CollectionDetail --> Product\n  1126→    Home --> Search[\"/search?q=...\"]\n  1127→    Search --> Product\n  1128→    Home --> Pages[\"/pages/{handle}\"]\n  1129→    Product --> Cart[\"/cart\"]\n  1130→```\n  1131→\n  1132→---\n  1133→\n  1134→### Test 7.1: Shows featured products on home page\n  1135→\n  1136→**Preconditions:** Seeded products\n  1137→\n  1138→**Steps:**\n  1139→1. Navigate to `/`\n  1140→\n  1141→**Expected Results:**\n  1142→- Page displays \"Acme Fashion\"\n  1143→- Page displays \"Classic Cotton T-Shirt\"\n  1144→- Page displays \"24.99\"\n  1145→- No JavaScript errors\n  1146→\n  1147→---\n  1148→\n  1149→### Test 7.2: Shows collection with product grid\n  1150→\n  1151→**Preconditions:** Seeded collections and products\n  1152→\n  1153→**Steps:**\n  1154→1. Navigate to `/collections/t-shirts`\n  1155→\n  1156→**Expected Results:**\n  1157→- Page displays \"T-Shirts\"\n  1158→- Page displays \"Classic Cotton T-Shirt\"\n  1159→- No JavaScript errors\n  1160→\n  1161→---\n  1162→\n  1163→### Test 7.3: Can navigate from collection to product\n  1164→\n  1165→**Preconditions:** Seeded collections and products\n  1166→\n  1167→**Steps:**\n  1168→1. Navigate to `/collections/t-shirts`\n  1169→2. Click \"Classic Cotton T-Shirt\"\n  1170→\n  1171→**Expected Results:**\n  1172→- Page displays \"Classic Cotton T-Shirt\"\n  1173→- Page displays \"24.99\"\n  1174→- Page displays \"Add to cart\"\n  1175→- No JavaScript errors\n  1176→\n  1177→---\n  1178→\n  1179→### Test 7.4: Shows product detail with variant options\n  1180→\n  1181→**Preconditions:** Product #1\n  1182→\n  1183→**Steps:**\n  1184→1. Navigate to `/products/classic-cotton-t-shirt`\n  1185→\n  1186→**Expected Results:**\n  1187→- Page displays \"Classic Cotton T-Shirt\"\n  1188→- Page displays \"24.99\"\n  1189→- Page displays \"Size\"\n  1190→- Page displays \"Color\"\n  1191→- No JavaScript errors\n  1192→\n  1193→---\n  1194→\n  1195→### Test 7.5: Shows size and color option values\n  1196→\n  1197→**Preconditions:** Product #1\n  1198→\n  1199→**Steps:**\n  1200→1. Navigate to `/products/classic-cotton-t-shirt`\n  1201→\n  1202→**Expected Results:**\n  1203→- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n  1204→- Page displays color options: \"Black\", \"White\", \"Navy\"\n  1205→- No JavaScript errors\n  1206→\n  1207→---\n  1208→\n  1209→### Test 7.6: Updates price when variant changes on product with compare-at pricing\n  1210→\n  1211→**Preconditions:** Product #2 with compare-at pricing\n  1212→\n  1213→**Steps:**\n  1214→1. Navigate to `/products/premium-slim-fit-jeans`\n  1215→2. Select a variant that has a compare-at price (sale variant)\n  1216→\n  1217→**Expected Results:**\n  1218→- Page displays \"Premium Slim Fit Jeans\"\n  1219→- Sale price is displayed\n  1220→- Compare-at (original) price is shown with strikethrough styling\n  1221→- No JavaScript errors\n  1222→\n  1223→---\n  1224→\n  1225→### Test 7.7: Shows search results for valid query\n  1226→\n  1227→**Preconditions:** Seeded products\n  1228→\n  1229→**Steps:**\n  1230→1. Navigate to `/search?q=cotton`\n  1231→\n  1232→**Expected Results:**\n  1233→- Page displays \"Classic Cotton T-Shirt\" in results\n  1234→- No JavaScript errors\n  1235→\n  1236→---\n  1237→\n  1238→### Test 7.8: Shows no results message for invalid query\n  1239→\n  1240→**Preconditions:** None\n  1241→\n  1242→**Steps:**\n  1243→1. Navigate to `/search?q=zznonexistentproductzz`\n  1244→\n  1245→**Expected Results:**\n  1246→- Page displays \"No results\"\n  1247→- No JavaScript errors\n  1248→\n  1249→---\n  1250→\n  1251→### Test 7.9: Does not show draft products on storefront collections\n  1252→\n  1253→**Preconditions:** Product #15 with draft status\n  1254→\n  1255→**Steps:**\n  1256→1. Navigate to `/collections`\n  1257→\n  1258→**Expected Results:**\n  1259→- Product #15 (draft) must NOT appear anywhere in the listing\n  1260→- No JavaScript errors\n  1261→\n  1262→---\n  1263→\n  1264→### Test 7.10: Does not show draft products in search results\n  1265→\n  1266→**Preconditions:** Product #15 with draft status\n  1267→\n  1268→**Steps:**\n  1269→1. Navigate to `/search?q=draft`\n  1270→\n  1271→**Expected Results:**\n  1272→- No draft products appear in search results\n  1273→- No JavaScript errors\n  1274→\n  1275→---\n  1276→\n  1277→### Test 7.11: Shows out of stock messaging for deny-policy product\n  1278→\n  1279→**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n  1280→\n  1281→**Steps:**\n  1282→1. Navigate to `/products/limited-edition-sneakers`\n  1283→\n  1284→**Expected Results:**\n  1285→- Page displays \"Sold out\"\n  1286→- \"Add to cart\" button is disabled or hidden\n  1287→- No JavaScript errors\n  1288→\n  1289→---\n  1290→\n  1291→### Test 7.12: Shows backorder messaging for continue-policy product\n  1292→\n  1293→**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n  1294→\n  1295→**Steps:**\n  1296→1. Navigate to `/products/backorder-denim-jacket`\n  1297→\n  1298→**Expected Results:**\n  1299→- Page displays \"Available on backorder\"\n  1300→- \"Add to cart\" button is still enabled\n  1301→- No JavaScript errors\n  1302→\n  1303→---\n  1304→\n  1305→### Test 7.13: Shows new arrivals collection\n  1306→\n  1307→**Preconditions:** Seeded collections\n  1308→\n  1309→**Steps:**\n  1310→1. Navigate to `/collections/new-arrivals`\n  1311→\n  1312→**Expected Results:**\n  1313→- Page displays \"New Arrivals\"\n  1314→- No JavaScript errors\n  1315→\n  1316→---\n  1317→\n  1318→### Test 7.14: Shows static about page\n  1319→\n  1320→**Preconditions:** Seeded pages\n  1321→\n  1322→**Steps:**\n  1323→1. Navigate to `/pages/about`\n  1324→\n  1325→**Expected Results:**\n  1326→- Page displays \"About\"\n  1327→- No JavaScript errors\n  1328→"}]},"uuid":"cec35970-c109-43a2-996e-12c9091c54e6","timestamp":"2026-02-11T15:20:06.815Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n","numLines":500,"startLine":829,"totalLines":2977}},"sourceToolAssistantUUID":"c00cf714-cd41-49bc-8c98-230664b9db44"}
{"parentUuid":"cec35970-c109-43a2-996e-12c9091c54e6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018MqV9pxUfPMMGQHtijvGbk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011CeDELyYkL3J2ZSCYxYi4u","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":23,"limit":10}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":23910,"cache_read_input_tokens":75230,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":23910},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WNcCdChwVBTzbBHcMs","type":"assistant","uuid":"68f919c5-06d2-4102-a5ad-895618683969","timestamp":"2026-02-11T15:20:07.382Z"}
{"parentUuid":"68f919c5-06d2-4102-a5ad-895618683969","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_011CeDELyYkL3J2ZSCYxYi4u","toolUseID":"toolu_011CeDELyYkL3J2ZSCYxYi4u","timestamp":"2026-02-11T15:20:07.386Z","uuid":"295d14c0-62d7-4f90-b5f5-91683c043503"}
{"parentUuid":"68f919c5-06d2-4102-a5ad-895618683969","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011CeDELyYkL3J2ZSCYxYi4u","type":"tool_result","content":"    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 | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\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→"}]},"uuid":"400bf8ad-ac64-40d4-a12d-6882c9d84439","timestamp":"2026-02-11T15:20:07.386Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| Payments | Stripe + PayPal | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\n| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n","numLines":10,"startLine":23,"totalLines":2027}},"sourceToolAssistantUUID":"68f919c5-06d2-4102-a5ad-895618683969"}
{"parentUuid":"400bf8ad-ac64-40d4-a12d-6882c9d84439","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Ns4ELucfRUGiGACaXdkjXu","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me also read the checkout test suite in 08-PLAYWRIGHT-E2E-PLAN.md.","signature":"EvIBCkYICxgCKkAziZ+H3PSUU8L/jMp4xESMG0Y7L7E/AfkKvc8rg25tS9JYjv8RO5V6Xowjmevp8vNW5fUlNELpgWCC6WTZ/pOhEgzsrKN/a+L9DgelnZoaDK1YhT624dSxyt3yCSIw+VdU+xbezS8sFQruMA4MPOxXdcr1YslV0wcmoYnqo7qns2cIHyY/G5Aa+NP7gkhxKlr0EslvK1TXoDch5cQVJr/vPqGqxz+x6BJMjf3CYccFCNjvwtbWBIN9Kn8Wdae0Ie9jShaF6YB9CANN7YPFHUd1IMsnO1JkcmLACaEwHfY7RustyO1OIPmfDzkYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6368,"cache_read_input_tokens":99140,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6368},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WPHvZUXiDSBa4VKDp4","type":"assistant","uuid":"b0cbb341-b739-4fd0-b90e-2d84e8f21712","timestamp":"2026-02-11T15:20:10.721Z"}
{"parentUuid":"b0cbb341-b739-4fd0-b90e-2d84e8f21712","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Ns4ELucfRUGiGACaXdkjXu","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the checkout test suite (Suite 9)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6368,"cache_read_input_tokens":99140,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6368},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WPHvZUXiDSBa4VKDp4","type":"assistant","uuid":"157a6a0a-b092-49d1-bfa0-3a2edba0878c","timestamp":"2026-02-11T15:20:11.211Z"}
{"parentUuid":"157a6a0a-b092-49d1-bfa0-3a2edba0878c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Ns4ELucfRUGiGACaXdkjXu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015ii7DpBLy1i2yD4yEZP6Wk","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":1618,"limit":300}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6368,"cache_read_input_tokens":99140,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6368},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WPHvZUXiDSBa4VKDp4","type":"assistant","uuid":"fda233b1-9f4f-42a7-884c-acdcb521ac70","timestamp":"2026-02-11T15:20:12.580Z"}
{"parentUuid":"fda233b1-9f4f-42a7-884c-acdcb521ac70","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_015ii7DpBLy1i2yD4yEZP6Wk","toolUseID":"toolu_015ii7DpBLy1i2yD4yEZP6Wk","timestamp":"2026-02-11T15:20:12.582Z","uuid":"fc326748-9966-4abf-9eab-81184767d479"}
{"parentUuid":"fda233b1-9f4f-42a7-884c-acdcb521ac70","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015ii7DpBLy1i2yD4yEZP6Wk","type":"tool_result","content":"  1618→## Suite 9: Checkout Flow\n  1619→\n  1620→**File:** `tests/Browser/Storefront/CheckoutTest.php`\n  1621→\n  1622→**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n  1623→\n  1624→### Checkout Step Flow\n  1625→\n  1626→```mermaid\n  1627→flowchart TD\n  1628→    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n  1629→    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n  1630→    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n  1631→    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]\n  1632→    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]\n  1633→\n  1634→    Step1 -->|empty email| Step1Error[Validation error on email]\n  1635→    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n  1636→    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n  1637→\n  1638→    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n  1639→    Step2 -->|Country = US| InternationalRates[International rates]\n  1640→\n  1641→    Cart -->|Empty cart| EmptyState[Checkout disabled]\n  1642→```\n  1643→\n  1644→### Checkout Step Detail\n  1645→\n  1646→| Step | Section | Required Fields | Visible After Completion |\n  1647→|------|---------|-----------------|--------------------------|\n  1648→| 1. Contact | Contact section | email | Email summary shown |\n  1649→| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n  1650→| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n  1651→| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |\n  1652→| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |\n  1653→\n  1654→### Totals Calculation Reference\n  1655→\n  1656→For the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n  1657→\n  1658→| Line | Amount |\n  1659→|------|--------|\n  1660→| Subtotal | 24.99 EUR |\n  1661→| Shipping (Standard, Domestic DE) | 4.99 EUR |\n  1662→| Tax (included in price for DE, manual mode) | varies |\n  1663→| **Total** | **29.98 EUR** |\n  1664→\n  1665→With FLAT5 discount applied:\n  1666→\n  1667→| Line | Amount |\n  1668→|------|--------|\n  1669→| Subtotal | 24.99 EUR |\n  1670→| Discount (FLAT5) | -5.00 EUR |\n  1671→| Shipping | 4.99 EUR |\n  1672→| **Total** | **24.98 EUR** |\n  1673→\n  1674→---\n  1675→\n  1676→### Test 9.1: Completes full checkout happy path\n  1677→\n  1678→**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n  1679→\n  1680→**Steps:**\n  1681→1. Navigate to `/products/classic-cotton-t-shirt`\n  1682→2. Click \"M\" to select size M\n  1683→3. Click \"Black\" to select color Black\n  1684→4. Click \"Add to cart\"\n  1685→5. Navigate to `/cart`\n  1686→6. Click \"Checkout\"\n  1687→7. Enter email: `test-buyer@example.com`\n  1688→8. Click \"Continue\"\n  1689→9. Enter first name: `Test`\n  1690→10. Enter last name: `Buyer`\n  1691→11. Enter address: `Teststrasse 1`\n  1692→12. Enter city: `Berlin`\n  1693→13. Enter postal code: `10115`\n  1694→14. Enter country: `DE`\n  1695→15. Click \"Continue\"\n  1696→16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n  1697→17. Click \"Standard Shipping\" to select it\n  1698→18. Click \"Continue\"\n  1699→19. Verify total is 29.98 (24.99 + 4.99 shipping)\n  1700→20. Click \"Pay now\" (test/mock payment flow)\n  1701→\n  1702→**Expected Results:**\n  1703→- Confirmation page displays \"Thank you\"\n  1704→- Confirmation page displays an order number (prefixed with \"#\")\n  1705→- No JavaScript errors\n  1706→\n  1707→---\n  1708→\n  1709→### Test 9.2: Shows shipping methods based on German address\n  1710→\n  1711→**Preconditions:** Product #1 in cart, Domestic shipping zone\n  1712→\n  1713→**Steps:**\n  1714→1. Navigate to `/products/classic-cotton-t-shirt`\n  1715→2. Click \"M\" to select size M\n  1716→3. Click \"Black\" to select color Black\n  1717→4. Click \"Add to cart\"\n  1718→5. Navigate to `/cart`\n  1719→6. Click \"Checkout\"\n  1720→7. Enter email: `test@example.com`\n  1721→8. Click \"Continue\"\n  1722→9. Enter first name: `Hans`\n  1723→10. Enter last name: `Mueller`\n  1724→11. Enter address: `Berliner Str. 10`\n  1725→12. Enter city: `Munich`\n  1726→13. Enter postal code: `80331`\n  1727→14. Enter country: `DE`\n  1728→15. Click \"Continue\"\n  1729→\n  1730→**Expected Results:**\n  1731→- Page displays \"Standard Shipping\"\n  1732→- Page displays \"4.99\" (Domestic zone rate)\n  1733→- No JavaScript errors\n  1734→\n  1735→---\n  1736→\n  1737→### Test 9.3: Shows international shipping methods for non-DE address\n  1738→\n  1739→**Preconditions:** Product #1 in cart, International shipping zone\n  1740→\n  1741→**Steps:**\n  1742→1. Navigate to `/products/classic-cotton-t-shirt`\n  1743→2. Click \"M\" to select size M\n  1744→3. Click \"Black\" to select color Black\n  1745→4. Click \"Add to cart\"\n  1746→5. Navigate to `/cart`\n  1747→6. Click \"Checkout\"\n  1748→7. Enter email: `test@example.com`\n  1749→8. Click \"Continue\"\n  1750→9. Enter first name: `John`\n  1751→10. Enter last name: `Smith`\n  1752→11. Enter address: `123 Main St`\n  1753→12. Enter city: `New York`\n  1754→13. Enter postal code: `10001`\n  1755→14. Enter country: `US`\n  1756→15. Click \"Continue\"\n  1757→\n  1758→**Expected Results:**\n  1759→- Page displays \"International\" shipping rates (not Domestic rates)\n  1760→- No JavaScript errors\n  1761→\n  1762→---\n  1763→\n  1764→### Test 9.4: Applies discount during checkout\n  1765→\n  1766→**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n  1767→\n  1768→**Steps:**\n  1769→1. Navigate to `/products/classic-cotton-t-shirt`\n  1770→2. Click \"M\" to select size M\n  1771→3. Click \"Black\" to select color Black\n  1772→4. Click \"Add to cart\"\n  1773→5. Navigate to `/cart`\n  1774→6. Enter discount code: `FLAT5` in the discount_code field\n  1775→7. Click \"Apply\"\n  1776→8. Verify \"FLAT5\" is displayed\n  1777→9. Click \"Checkout\"\n  1778→10. Enter email: `test@example.com`\n  1779→11. Click \"Continue\"\n  1780→12. Enter first name: `Test`\n  1781→13. Enter last name: `User`\n  1782→14. Enter address: `Teststr 1`\n  1783→15. Enter city: `Berlin`\n  1784→16. Enter postal code: `10115`\n  1785→17. Enter country: `DE`\n  1786→18. Click \"Continue\"\n  1787→19. Click \"Standard Shipping\" to select it\n  1788→20. Click \"Continue\"\n  1789→\n  1790→**Expected Results:**\n  1791→- Page displays \"FLAT5\" label in checkout totals\n  1792→- Page displays \"5.00\" discount amount\n  1793→- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n  1794→- No JavaScript errors\n  1795→\n  1796→---\n  1797→\n  1798→### Test 9.5: Validates required contact email\n  1799→\n  1800→**Preconditions:** Product #1 in cart\n  1801→\n  1802→**Steps:**\n  1803→1. Navigate to `/products/classic-cotton-t-shirt`\n  1804→2. Click \"M\" to select size M\n  1805→3. Click \"Black\" to select color Black\n  1806→4. Click \"Add to cart\"\n  1807→5. Navigate to `/cart`\n  1808→6. Click \"Checkout\"\n  1809→7. Click \"Continue\" without entering an email\n  1810→\n  1811→**Expected Results:**\n  1812→- Validation error referencing \"email\" is displayed\n  1813→- No JavaScript errors\n  1814→\n  1815→---\n  1816→\n  1817→### Test 9.6: Validates required shipping address fields\n  1818→\n  1819→**Preconditions:** Product #1 in cart\n  1820→\n  1821→**Steps:**\n  1822→1. Navigate to `/products/classic-cotton-t-shirt`\n  1823→2. Click \"M\" to select size M\n  1824→3. Click \"Black\" to select color Black\n  1825→4. Click \"Add to cart\"\n  1826→5. Navigate to `/cart`\n  1827→6. Click \"Checkout\"\n  1828→7. Enter email: `test@example.com`\n  1829→8. Click \"Continue\"\n  1830→9. Click \"Continue\" without filling any address fields\n  1831→\n  1832→**Expected Results:**\n  1833→- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n  1834→- No JavaScript errors\n  1835→\n  1836→---\n  1837→\n  1838→### Test 9.7: Validates invalid postal code format\n  1839→\n  1840→**Preconditions:** Product #1 in cart\n  1841→\n  1842→**Steps:**\n  1843→1. Navigate to `/products/classic-cotton-t-shirt`\n  1844→2. Click \"M\" to select size M\n  1845→3. Click \"Black\" to select color Black\n  1846→4. Click \"Add to cart\"\n  1847→5. Navigate to `/cart`\n  1848→6. Click \"Checkout\"\n  1849→7. Enter email: `test@example.com`\n  1850→8. Click \"Continue\"\n  1851→9. Enter first name: `Test`\n  1852→10. Enter last name: `User`\n  1853→11. Enter address: `Teststr 1`\n  1854→12. Enter city: `Berlin`\n  1855→13. Enter postal code: `INVALID`\n  1856→14. Enter country: `DE`\n  1857→15. Click \"Continue\"\n  1858→\n  1859→**Expected Results:**\n  1860→- Validation error for postal code format is displayed\n  1861→- No JavaScript errors\n  1862→\n  1863→---\n  1864→\n  1865→### Test 9.8: Prevents checkout with empty cart\n  1866→\n  1867→**Preconditions:** No products in cart\n  1868→\n  1869→**Steps:**\n  1870→1. Navigate to `/cart` (without adding any items)\n  1871→\n  1872→**Expected Results:**\n  1873→- Page displays \"Your cart is empty\"\n  1874→- \"Checkout\" button is disabled or not shown\n  1875→- No JavaScript errors\n  1876→\n  1877→**Total: 8 tests**\n  1878→\n  1879→---\n  1880→\n  1881→## Suite 10: Customer Account\n  1882→\n  1883→**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n  1884→\n  1885→**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n  1886→\n  1887→### Customer Account Flow\n  1888→\n  1889→```mermaid\n  1890→flowchart TD\n  1891→    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n  1892→    Guest -->|visit /account/login| Login[Login Form]\n  1893→    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n  1894→    Login -->|valid credentials + click 'Log in'| Dashboard\n  1895→    Login -->|invalid credentials| Login\n  1896→    Dashboard --> OrderHistory[/account/orders]\n  1897→    OrderHistory --> OrderDetail[/account/orders/number]\n  1898→    Dashboard --> Addresses[/account/addresses]\n  1899→    Addresses -->|click 'Add address'| AddAddress[Address form]\n  1900→    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n  1901→    Dashboard -->|click 'Logout'| Guest\n  1902→```\n  1903→\n  1904→---\n  1905→\n  1906→### Test 10.1: Can register a new customer\n  1907→\n  1908→**Preconditions:** None\n  1909→\n  1910→**Steps:**\n  1911→1. Navigate to `/account/register`\n  1912→2. Enter name: `New Customer`\n  1913→3. Enter email: `new-customer-e2e@example.com`\n  1914→4. Enter password: `password123`\n  1915→5. Enter password confirmation: `password123`\n  1916→6. Click \"Create account\"\n  1917→"}]},"uuid":"57eb8f1f-f958-43f5-99c6-d1ee7e616868","timestamp":"2026-02-11T15:20:12.582Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]\n    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]\n\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout happy path\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify total is 29.98 (24.99 + 4.99 shipping)\n20. Click \"Pay now\" (test/mock payment flow)\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n","numLines":300,"startLine":1618,"totalLines":2977}},"sourceToolAssistantUUID":"fda233b1-9f4f-42a7-884c-acdcb521ac70"}
{"parentUuid":"57eb8f1f-f958-43f5-99c6-d1ee7e616868","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QqVPSuauT9kvJK8RTnLnaL","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have all the information I need. Let me start making the edits. I'll work through each file systematically.\n\nLet me start with finishing Task #3 (02-API-ROUTES.md), then move to the other tasks.\n\nFor 02-API-ROUTES.md, the remaining changes are:\n1. Fix the order response example that shows `\"provider\": \"stripe\"` (line 1946) - change to \"mock\" and add \"method\"\n2. Remove the entire Section 4 (Webhook Receiver Endpoints) - lines 2850-2985\n3. Remove webhooks middleware section (lines 3131-3139)\n","signature":"EusICkYICxgCKkA4A8Ctg65T9xTQnMHk+G934v/f/3vn4IrAC9BgNQ4bzVHZIi0SC/omCRMML5y0v/WyXO2+ZZu5NZt2ij5oO40CEgzB6f/bENd52ojWed8aDBLsrQcN1AlfT++zXiIwBWnd4mxMUgTqKfRJvYqEWjJvN3Zd0o+T8Ugvzx3siVfzGXqqIZG6/0l4OPfP6RMsKtIH4+y3QfJxxAvLamRLePXSfY0ojRTI289MKoyF1+6Z022AjmgodcC8QeWPEd8OqSkrC0sVLoNALQ5142RbMbKEleAD8DUrMJaGqRcOVMTGwFEJE0Zxcy64HZlZB6sYHgHmh0q5NekX9LAy/G8N3MxH/1b230G2xGf/oEfF6Dj/PsShDLE7Xzua7MG426ygCAq4K6S8HmCHGDMStDf4FUZg1K0HHoASugnCsBtb3wCF4P3QwFQOExLai4yVhctaPem6mk0qbzi5oC7VtsC2blytJL//gdKQ07fojGxCIFCSRKXpWTsXBLEMUeg7h6S62YHAENbxR4WORi/NOv2/cmNtJBkMAOLlGxL3F8Wqqr7A6x0pnVQja8ea4a9H30GoZyPUgLOVvXzFfw8+ty32505zgvIXfcvvwP7xqYpGjNi36cOhQ354efG5Glbd+avKBVfk4b4wlcyCU7IgQGfSQzJZm4A3eR7AjPjispfgFBQVlQ/wfMDCHiSvEtFyYFuNnY9dpolxV2SDBFzTFiQKeyLEy5r+xdzpFEsoA+32Otyj2g7SLDYrSnRtY0qjBpMgR0c4lgtbcakf8YarLtO3uJhnBDLoLucqAHmQ2maPgBhx0QNrmqbbTUNy8oVY22P1krS4YsnRbJq3//dmY2DemeU4yibOY4iO+QYgW3I41xFTbp4pR7XENL96jxJo98CUJN6K3AD5xYXScU+xVyEHBo/1G1dCFOUu8p+KUzY3t7dfo8EQTZahb+AFnzyRfB8SY0/61YL31RvgNBn8frMvdHFKJIEr4cwa/15YVKJ/EzVWpZM/QWqCD1FzTVfccu2D+fN1FV2/Yynbk9m7KpffywS94CK9vjBGVCVVJH4V67DLVp7c02u2ZwmbH1AwdGBgagAaUFRPnmefSWt8f5IqBPKwIyrt89GQEXvIk+Rco+LWe0n54zWnGV8NGCuaY9+Uk6KLtw8gww22nuUbTRWx1rmCpAdj2MPEZKs5nWhU2R7H4QgjUZqDhZqJ04PMRttIXVfdJ8T5UlggGi87VYgfw+ztkYcTHbRzs4v33lteC3Kl4t9tq1KDfzYaGGoEMEeEHm4e/aPXmprNeGYV7PH/Zg8FqJ9DPpDLvymJVm0v1fQD1mUl+WPtWbxmHwEMZ+DIdxwTqoJdYtU/4PpjBBR5VXLS2spsPzLQ6U+24rIv8zUt7i8F2x6s0JrijyMFdxlm+E6Z6RY3az7NSMg6cHSLEnjFWNmobUsIjjEwwssz06WwDNJRL7YP2tAUcYH8vHHfPB2W+EicqxyP5kLx9st3nq6ukfwpGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4456,"cache_read_input_tokens":105508,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4456},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WPfHgvT6WRrPtPVVgM","type":"assistant","uuid":"51f012e7-ee5d-4a66-b6a8-2e90cee72b4d","timestamp":"2026-02-11T15:20:21.940Z"}
{"parentUuid":"51f012e7-ee5d-4a66-b6a8-2e90cee72b4d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QqVPSuauT9kvJK8RTnLnaL","type":"message","role":"assistant","content":[{"type":"text","text":"Now I have all the context I need. Let me start making edits. I'll work through the files systematically, starting with finishing Task #3 (02-API-ROUTES.md)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4456,"cache_read_input_tokens":105508,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4456},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WPfHgvT6WRrPtPVVgM","type":"assistant","uuid":"93506a4e-5504-4d7a-8ed5-c10d08e2ed6b","timestamp":"2026-02-11T15:20:22.719Z"}
{"parentUuid":"93506a4e-5504-4d7a-8ed5-c10d08e2ed6b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QqVPSuauT9kvJK8RTnLnaL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011UUV1UjnHg6NSVxvG1hKtu","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"        \"provider\": \"stripe\",\n        \"provider_payment_id\": \"pi_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"","new_string":"        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\""}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4456,"cache_read_input_tokens":105508,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4456},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WPfHgvT6WRrPtPVVgM","type":"assistant","uuid":"4354e0c3-cb0d-459e-b3f7-0f052796c33f","timestamp":"2026-02-11T15:20:25.971Z"}
{"parentUuid":"4354e0c3-cb0d-459e-b3f7-0f052796c33f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_011UUV1UjnHg6NSVxvG1hKtu","toolUseID":"toolu_011UUV1UjnHg6NSVxvG1hKtu","timestamp":"2026-02-11T15:20:25.978Z","uuid":"b51d79e9-d25c-4354-8a22-9676855cf58d"}
{"parentUuid":"4354e0c3-cb0d-459e-b3f7-0f052796c33f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011UUV1UjnHg6NSVxvG1hKtu","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"c2e828e7-1497-4940-a5a2-4ace67d4df82","timestamp":"2026-02-11T15:20:25.977Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"        \"provider\": \"stripe\",\n        \"provider_payment_id\": \"pi_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"","newString":"        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"stripe\",\n        \"provider_payment_id\": \"pi_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\nThese endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n\n**Prefix:** `/api/webhooks`\n**Middleware:** None (raw request body needed for signature verification)\n\n---\n\n### POST /api/webhooks/stripe\n\nReceive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n\n- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"evt_abc123\",\n  \"type\": \"payment_intent.succeeded\",\n  \"data\": {\n    \"object\": {\n      \"id\": \"pi_abc123\",\n      \"amount\": 6545,\n      \"currency\": \"eur\",\n      \"status\": \"succeeded\",\n      \"metadata\": {\n        \"checkout_id\": 7,\n        \"store_id\": 1\n      }\n    }\n  },\n  \"created\": 1707645000\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n2. Track idempotency via `event.id` to prevent duplicate processing\n3. Dispatch appropriate action based on event type\n\n**Supported Stripe event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `payment_intent.succeeded` | Transition order to `paid`, decrement inventory |\n| `payment_intent.payment_failed` | Mark payment as failed, notify customer |\n| `charge.refunded` | Process refund, update order financial_status |\n| `checkout.session.completed` | Reconcile Checkout Session flow |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Invalid signature or malformed payload\n\n---\n\n### POST /api/webhooks/paypal\n\nReceive PayPal webhook notifications.\n\n- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"WH-abc123\",\n  \"event_type\": \"PAYMENT.CAPTURE.COMPLETED\",\n  \"resource\": {\n    \"id\": \"5O190127TN364715T\",\n    \"status\": \"COMPLETED\",\n    \"amount\": {\n      \"currency_code\": \"EUR\",\n      \"value\": \"65.45\"\n    },\n    \"custom_id\": 7\n  },\n  \"create_time\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify signature by calling PayPal's verification endpoint\n2. Check `verification_status` is `SUCCESS`; reject if `FAILURE`\n3. Track idempotency via `event.id`\n4. Dispatch action based on `event_type`\n\n**Supported PayPal event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `PAYMENT.CAPTURE.COMPLETED` | Transition order to `paid`, decrement inventory |\n| `PAYMENT.CAPTURE.DENIED` | Mark payment as failed |\n| `PAYMENT.CAPTURE.REFUNDED` | Process refund |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Verification failed or malformed payload\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n#### Webhooks\n\nApplied to `/api/webhooks/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:webhooks` | Rate limiting (100/min per IP) |\n\nNo session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":1943,"oldLines":8,"newStart":1943,"newLines":9,"lines":["     \"payments\": [","       {","         \"id\": 401,","-        \"provider\": \"stripe\",","-        \"provider_payment_id\": \"pi_abc123\",","+        \"provider\": \"mock\",","+        \"method\": \"credit_card\",","+        \"provider_payment_id\": \"mock_abc123\",","         \"status\": \"captured\",","         \"amount\": 5950,","         \"currency\": \"EUR\","]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"4354e0c3-cb0d-459e-b3f7-0f052796c33f"}
{"parentUuid":"c2e828e7-1497-4940-a5a2-4ace67d4df82","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QqVPSuauT9kvJK8RTnLnaL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JExyKYrQDWjNjUS3Qis5ad","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"## 4. Webhook Receiver Endpoints\n\nThese endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n\n**Prefix:** `/api/webhooks`\n**Middleware:** None (raw request body needed for signature verification)\n\n---\n\n### POST /api/webhooks/stripe\n\nReceive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n\n- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"evt_abc123\",\n  \"type\": \"payment_intent.succeeded\",\n  \"data\": {\n    \"object\": {\n      \"id\": \"pi_abc123\",\n      \"amount\": 6545,\n      \"currency\": \"eur\",\n      \"status\": \"succeeded\",\n      \"metadata\": {\n        \"checkout_id\": 7,\n        \"store_id\": 1\n      }\n    }\n  },\n  \"created\": 1707645000\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n2. Track idempotency via `event.id` to prevent duplicate processing\n3. Dispatch appropriate action based on event type\n\n**Supported Stripe event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `payment_intent.succeeded` | Transition order to `paid`, decrement inventory |\n| `payment_intent.payment_failed` | Mark payment as failed, notify customer |\n| `charge.refunded` | Process refund, update order financial_status |\n| `checkout.session.completed` | Reconcile Checkout Session flow |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Invalid signature or malformed payload\n\n---\n\n### POST /api/webhooks/paypal\n\nReceive PayPal webhook notifications.\n\n- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"WH-abc123\",\n  \"event_type\": \"PAYMENT.CAPTURE.COMPLETED\",\n  \"resource\": {\n    \"id\": \"5O190127TN364715T\",\n    \"status\": \"COMPLETED\",\n    \"amount\": {\n      \"currency_code\": \"EUR\",\n      \"value\": \"65.45\"\n    },\n    \"custom_id\": 7\n  },\n  \"create_time\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify signature by calling PayPal's verification endpoint\n2. Check `verification_status` is `SUCCESS`; reject if `FAILURE`\n3. Track idempotency via `event.id`\n4. Dispatch action based on `event_type`\n\n**Supported PayPal event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `PAYMENT.CAPTURE.COMPLETED` | Transition order to `paid`, decrement inventory |\n| `PAYMENT.CAPTURE.DENIED` | Mark payment as failed |\n| `PAYMENT.CAPTURE.REFUNDED` | Process refund |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Verification failed or malformed payload\n\n---","new_string":"## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4456,"cache_read_input_tokens":105508,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4456},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WPfHgvT6WRrPtPVVgM","type":"assistant","uuid":"47a64674-8375-4942-bf9f-bb632998bed4","timestamp":"2026-02-11T15:20:45.625Z"}
{"parentUuid":"47a64674-8375-4942-bf9f-bb632998bed4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01JExyKYrQDWjNjUS3Qis5ad","toolUseID":"toolu_01JExyKYrQDWjNjUS3Qis5ad","timestamp":"2026-02-11T15:20:45.633Z","uuid":"763a2ed7-b4ae-4b9d-8013-a8ed8b40f073"}
{"parentUuid":"47a64674-8375-4942-bf9f-bb632998bed4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JExyKYrQDWjNjUS3Qis5ad","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"78237d9f-bd16-4e00-9736-8b0a10a57ec4","timestamp":"2026-02-11T15:20:45.632Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"## 4. Webhook Receiver Endpoints\n\nThese endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n\n**Prefix:** `/api/webhooks`\n**Middleware:** None (raw request body needed for signature verification)\n\n---\n\n### POST /api/webhooks/stripe\n\nReceive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n\n- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"evt_abc123\",\n  \"type\": \"payment_intent.succeeded\",\n  \"data\": {\n    \"object\": {\n      \"id\": \"pi_abc123\",\n      \"amount\": 6545,\n      \"currency\": \"eur\",\n      \"status\": \"succeeded\",\n      \"metadata\": {\n        \"checkout_id\": 7,\n        \"store_id\": 1\n      }\n    }\n  },\n  \"created\": 1707645000\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n2. Track idempotency via `event.id` to prevent duplicate processing\n3. Dispatch appropriate action based on event type\n\n**Supported Stripe event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `payment_intent.succeeded` | Transition order to `paid`, decrement inventory |\n| `payment_intent.payment_failed` | Mark payment as failed, notify customer |\n| `charge.refunded` | Process refund, update order financial_status |\n| `checkout.session.completed` | Reconcile Checkout Session flow |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Invalid signature or malformed payload\n\n---\n\n### POST /api/webhooks/paypal\n\nReceive PayPal webhook notifications.\n\n- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"WH-abc123\",\n  \"event_type\": \"PAYMENT.CAPTURE.COMPLETED\",\n  \"resource\": {\n    \"id\": \"5O190127TN364715T\",\n    \"status\": \"COMPLETED\",\n    \"amount\": {\n      \"currency_code\": \"EUR\",\n      \"value\": \"65.45\"\n    },\n    \"custom_id\": 7\n  },\n  \"create_time\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify signature by calling PayPal's verification endpoint\n2. Check `verification_status` is `SUCCESS`; reject if `FAILURE`\n3. Track idempotency via `event.id`\n4. Dispatch action based on `event_type`\n\n**Supported PayPal event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `PAYMENT.CAPTURE.COMPLETED` | Transition order to `paid`, decrement inventory |\n| `PAYMENT.CAPTURE.DENIED` | Mark payment as failed |\n| `PAYMENT.CAPTURE.REFUNDED` | Process refund |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Verification failed or malformed payload\n\n---","newString":"## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\nThese endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.\n\n**Prefix:** `/api/webhooks`\n**Middleware:** None (raw request body needed for signature verification)\n\n---\n\n### POST /api/webhooks/stripe\n\nReceive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).\n\n- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `Stripe-Signature` | Yes | HMAC signature for payload verification |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"evt_abc123\",\n  \"type\": \"payment_intent.succeeded\",\n  \"data\": {\n    \"object\": {\n      \"id\": \"pi_abc123\",\n      \"amount\": 6545,\n      \"currency\": \"eur\",\n      \"status\": \"succeeded\",\n      \"metadata\": {\n        \"checkout_id\": 7,\n        \"store_id\": 1\n      }\n    }\n  },\n  \"created\": 1707645000\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`\n2. Track idempotency via `event.id` to prevent duplicate processing\n3. Dispatch appropriate action based on event type\n\n**Supported Stripe event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `payment_intent.succeeded` | Transition order to `paid`, decrement inventory |\n| `payment_intent.payment_failed` | Mark payment as failed, notify customer |\n| `charge.refunded` | Process refund, update order financial_status |\n| `checkout.session.completed` | Reconcile Checkout Session flow |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Invalid signature or malformed payload\n\n---\n\n### POST /api/webhooks/paypal\n\nReceive PayPal webhook notifications.\n\n- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.\n- **Rate limit:** `webhooks` (100/min per IP)\n\n**Required headers:**\n\n| Header | Required | Description |\n|--------|----------|-------------|\n| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |\n| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |\n| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |\n| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |\n| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |\n| `Content-Type` | Yes | `application/json` |\n\n**Example event payload:**\n\n```json\n{\n  \"id\": \"WH-abc123\",\n  \"event_type\": \"PAYMENT.CAPTURE.COMPLETED\",\n  \"resource\": {\n    \"id\": \"5O190127TN364715T\",\n    \"status\": \"COMPLETED\",\n    \"amount\": {\n      \"currency_code\": \"EUR\",\n      \"value\": \"65.45\"\n    },\n    \"custom_id\": 7\n  },\n  \"create_time\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Webhook processing steps:**\n\n1. Verify signature by calling PayPal's verification endpoint\n2. Check `verification_status` is `SUCCESS`; reject if `FAILURE`\n3. Track idempotency via `event.id`\n4. Dispatch action based on `event_type`\n\n**Supported PayPal event types and actions:**\n\n| Event Type | Action |\n|------------|--------|\n| `PAYMENT.CAPTURE.COMPLETED` | Transition order to `paid`, decrement inventory |\n| `PAYMENT.CAPTURE.DENIED` | Mark payment as failed |\n| `PAYMENT.CAPTURE.REFUNDED` | Process refund |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"received\": true\n}\n```\n\n**Error responses:**\n- `400` - Verification failed or malformed payload\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n#### Webhooks\n\nApplied to `/api/webhooks/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:webhooks` | Rate limiting (100/min per IP) |\n\nNo session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":2850,"oldLines":142,"newStart":2850,"newLines":10,"lines":[" "," ## 4. Webhook Receiver Endpoints"," ","-These endpoints receive incoming webhooks from payment providers. They are not authenticated via tokens but use signature verification to ensure authenticity.","+> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem)."," ","-**Prefix:** `/api/webhooks`","-**Middleware:** None (raw request body needed for signature verification)","-"," ---"," ","-### POST /api/webhooks/stripe","-","-Receive Stripe webhook events (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, etc.).","-","-- **Authentication:** `Stripe-Signature` header verification using `STRIPE_WEBHOOK_SECRET`","-- **Rate limit:** `webhooks` (100/min per IP)","-","-**Required headers:**","-","-| Header | Required | Description |","-|--------|----------|-------------|","-| `Stripe-Signature` | Yes | HMAC signature for payload verification |","-| `Content-Type` | Yes | `application/json` |","-","-**Example event payload:**","-","-```json","-{","-  \"id\": \"evt_abc123\",","-  \"type\": \"payment_intent.succeeded\",","-  \"data\": {","-    \"object\": {","-      \"id\": \"pi_abc123\",","-      \"amount\": 6545,","-      \"currency\": \"eur\",","-      \"status\": \"succeeded\",","-      \"metadata\": {","-        \"checkout_id\": 7,","-        \"store_id\": 1","-      }","-    }","-  },","-  \"created\": 1707645000","-}","-```","-","-**Webhook processing steps:**","-","-1. Verify `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET`","-2. Track idempotency via `event.id` to prevent duplicate processing","-3. Dispatch appropriate action based on event type","-","-**Supported Stripe event types and actions:**","-","-| Event Type | Action |","-|------------|--------|","-| `payment_intent.succeeded` | Transition order to `paid`, decrement inventory |","-| `payment_intent.payment_failed` | Mark payment as failed, notify customer |","-| `charge.refunded` | Process refund, update order financial_status |","-| `checkout.session.completed` | Reconcile Checkout Session flow |","-","-**Success response:** `200 OK`","-","-```json","-{","-  \"received\": true","-}","-```","-","-**Error responses:**","-- `400` - Invalid signature or malformed payload","-","----","-","-### POST /api/webhooks/paypal","-","-Receive PayPal webhook notifications.","-","-- **Authentication:** PayPal signature verification via PayPal's verification endpoint. Checks `verification_status` response.","-- **Rate limit:** `webhooks` (100/min per IP)","-","-**Required headers:**","-","-| Header | Required | Description |","-|--------|----------|-------------|","-| `PAYPAL-TRANSMISSION-SIG` | Yes | PayPal transmission signature |","-| `PAYPAL-TRANSMISSION-ID` | Yes | Unique transmission ID |","-| `PAYPAL-TRANSMISSION-TIME` | Yes | Transmission timestamp |","-| `PAYPAL-CERT-URL` | Yes | URL to PayPal's certificate |","-| `PAYPAL-AUTH-ALGO` | Yes | Algorithm used for signing |","-| `Content-Type` | Yes | `application/json` |","-","-**Example event payload:**","-","-```json","-{","-  \"id\": \"WH-abc123\",","-  \"event_type\": \"PAYMENT.CAPTURE.COMPLETED\",","-  \"resource\": {","-    \"id\": \"5O190127TN364715T\",","-    \"status\": \"COMPLETED\",","-    \"amount\": {","-      \"currency_code\": \"EUR\",","-      \"value\": \"65.45\"","-    },","-    \"custom_id\": 7","-  },","-  \"create_time\": \"2026-02-11T10:10:00Z\"","-}","-```","-","-**Webhook processing steps:**","-","-1. Verify signature by calling PayPal's verification endpoint","-2. Check `verification_status` is `SUCCESS`; reject if `FAILURE`","-3. Track idempotency via `event.id`","-4. Dispatch action based on `event_type`","-","-**Supported PayPal event types and actions:**","-","-| Event Type | Action |","-|------------|--------|","-| `PAYMENT.CAPTURE.COMPLETED` | Transition order to `paid`, decrement inventory |","-| `PAYMENT.CAPTURE.DENIED` | Mark payment as failed |","-| `PAYMENT.CAPTURE.REFUNDED` | Process refund |","-","-**Success response:** `200 OK`","-","-```json","-{","-  \"received\": true","-}","-```","-","-**Error responses:**","-- `400` - Verification failed or malformed payload","-","----","-"," ## 5. OAuth Endpoints (App Ecosystem)"," "," > **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"47a64674-8375-4942-bf9f-bb632998bed4"}
{"parentUuid":"78237d9f-bd16-4e00-9736-8b0a10a57ec4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QGjLkWfwX6JHmJ1HVs414Q","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018yDj7qFX2dmE9nc1zU9QqY","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"#### Webhooks\n\nApplied to `/api/webhooks/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:webhooks` | Rate limiting (100/min per IP) |\n\nNo session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.\n\n---","new_string":"---"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1991,"cache_read_input_tokens":109964,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1991},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WS6P7KHhxo8rxmaTqX","type":"assistant","uuid":"453a2bd8-449e-4b34-b411-b4351d8a8c7a","timestamp":"2026-02-11T15:20:50.474Z"}
{"parentUuid":"453a2bd8-449e-4b34-b411-b4351d8a8c7a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_018yDj7qFX2dmE9nc1zU9QqY","toolUseID":"toolu_018yDj7qFX2dmE9nc1zU9QqY","timestamp":"2026-02-11T15:20:50.479Z","uuid":"684e312b-6955-4553-8ffc-c7d94560d09d"}
{"parentUuid":"453a2bd8-449e-4b34-b411-b4351d8a8c7a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018yDj7qFX2dmE9nc1zU9QqY","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"038be176-a168-4405-9bdf-d08354a19315","timestamp":"2026-02-11T15:20:50.479Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"#### Webhooks\n\nApplied to `/api/webhooks/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:webhooks` | Rate limiting (100/min per IP) |\n\nNo session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.\n\n---","newString":"---","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n#### Webhooks\n\nApplied to `/api/webhooks/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:webhooks` | Rate limiting (100/min per IP) |\n\nNo session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":2997,"oldLines":16,"newStart":2997,"newLines":6,"lines":[" | `store.resolve` | Resolve store from route parameter or token context |"," | `throttle:api.admin` | Rate limiting (60/min per token) |"," ","-#### Webhooks","-","-Applied to `/api/webhooks/*` routes:","-","-| Middleware | Purpose |","-|-----------|---------|","-| `throttle:webhooks` | Rate limiting (100/min per IP) |","-","-No session, CSRF, or auth middleware. Raw request body must be preserved for signature verification.","-"," ---"," "," ### 6.2 Custom Middleware Definitions"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"453a2bd8-449e-4b34-b411-b4351d8a8c7a"}
{"parentUuid":"038be176-a168-4405-9bdf-d08354a19315","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QGjLkWfwX6JHmJ1HVs414Q","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015yJzcoHra2eU25H7EyCJ6S","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |","new_string":"| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1991,"cache_read_input_tokens":109964,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1991},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WS6P7KHhxo8rxmaTqX","type":"assistant","uuid":"2d298716-41ca-4e57-ae9f-312b393b7c29","timestamp":"2026-02-11T15:20:52.727Z"}
{"parentUuid":"2d298716-41ca-4e57-ae9f-312b393b7c29","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_015yJzcoHra2eU25H7EyCJ6S","toolUseID":"toolu_015yJzcoHra2eU25H7EyCJ6S","timestamp":"2026-02-11T15:20:52.735Z","uuid":"f2555e5e-1b49-4792-bf3e-101652929c8e"}
{"parentUuid":"2d298716-41ca-4e57-ae9f-312b393b7c29","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015yJzcoHra2eU25H7EyCJ6S","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"f803e7a8-39e6-4b20-8dba-3ac276c00834","timestamp":"2026-02-11T15:20:52.734Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |","newString":"| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":3037,"oldLines":7,"newStart":3037,"newLines":7,"lines":[" | `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |"," | `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |"," | `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |","-| `webhooks` | 100 requests | 1 minute | IP address | Inbound payment provider webhooks |","+| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |"," "," ### Rate Limit Response"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"2d298716-41ca-4e57-ae9f-312b393b7c29"}
{"parentUuid":"f803e7a8-39e6-4b20-8dba-3ac276c00834","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QGjLkWfwX6JHmJ1HVs414Q","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HgVMFCHM7JXCkzCGDWbGWs","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |","new_string":"| *(no inbound webhook endpoints - mock PSP)* | - |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1991,"cache_read_input_tokens":109964,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1991},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WS6P7KHhxo8rxmaTqX","type":"assistant","uuid":"2178bd75-b422-4973-8c93-4d683aca4d92","timestamp":"2026-02-11T15:20:54.661Z"}
{"parentUuid":"2178bd75-b422-4973-8c93-4d683aca4d92","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01HgVMFCHM7JXCkzCGDWbGWs","toolUseID":"toolu_01HgVMFCHM7JXCkzCGDWbGWs","timestamp":"2026-02-11T15:20:54.667Z","uuid":"2be1c45e-a9a6-41e6-a9ae-1f90332842b5"}
{"parentUuid":"2178bd75-b422-4973-8c93-4d683aca4d92","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HgVMFCHM7JXCkzCGDWbGWs","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"4c422e8f-ade4-4a69-ba39-a2e03186a519","timestamp":"2026-02-11T15:20:54.666Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |","newString":"| *(no inbound webhook endpoints - mock PSP)* | - |","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| `POST /api/webhooks/stripe` | `webhooks` |\n| `POST /api/webhooks/paypal` | `webhooks` |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":3081,"oldLines":8,"newStart":3081,"newLines":7,"lines":[" | `POST /api/admin/v1/*` | `api.admin` |"," | `PUT /api/admin/v1/*` | `api.admin` |"," | `DELETE /api/admin/v1/*` | `api.admin` |","-| `POST /api/webhooks/stripe` | `webhooks` |","-| `POST /api/webhooks/paypal` | `webhooks` |","+| *(no inbound webhook endpoints - mock PSP)* | - |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"2178bd75-b422-4973-8c93-4d683aca4d92"}
{"parentUuid":"4c422e8f-ade4-4a69-ba39-a2e03186a519","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017L9MBNtZq9dyfNBFf6Q1M8","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the checkout state machine to remove webhook references."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":718,"cache_read_input_tokens":111955,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":718},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WSmBUzcqWuAo5WF241","type":"assistant","uuid":"2fd2e2b4-bf26-4063-9d68-8c8b3d7b3ff2","timestamp":"2026-02-11T15:20:57.993Z"}
{"parentUuid":"2fd2e2b4-bf26-4063-9d68-8c8b3d7b3ff2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017L9MBNtZq9dyfNBFf6Q1M8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UVnbuGEsYkaxZtzvMFWX6h","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |","new_string":"```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)\n    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)\n    payment_selected --> shipping_selected : Payment declined, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_selected --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |\n| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |\n| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":718,"cache_read_input_tokens":111955,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":718},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WSmBUzcqWuAo5WF241","type":"assistant","uuid":"3f29ff60-e4f0-4a1f-8e49-f209806329b1","timestamp":"2026-02-11T15:21:09.490Z"}
{"parentUuid":"3f29ff60-e4f0-4a1f-8e49-f209806329b1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01UVnbuGEsYkaxZtzvMFWX6h","toolUseID":"toolu_01UVnbuGEsYkaxZtzvMFWX6h","timestamp":"2026-02-11T15:21:09.497Z","uuid":"154ee89f-cd0d-48ea-8178-af1e77e50b87"}
{"parentUuid":"3f29ff60-e4f0-4a1f-8e49-f209806329b1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UVnbuGEsYkaxZtzvMFWX6h","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"9c01ed0a-f086-46d1-a753-43ec9d85b91b","timestamp":"2026-02-11T15:21:09.496Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |","newString":"```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)\n    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)\n    payment_selected --> shipping_selected : Payment declined, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_selected --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |\n| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |\n| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| *(no inbound webhook endpoints - mock PSP)* | - |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_pending : POST /payments/* (create intent/session)\n    payment_pending --> completed : Webhook confirms successful payment\n    payment_pending --> shipping_selected : Payment failed, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_pending --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |\n| `payment_pending` | `completed` | Successful payment confirmed via webhook |\n| `payment_pending` | `shipping_selected` | Payment failed, customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":3094,"oldLines":13,"newStart":3094,"newLines":14,"lines":["     [*] --> started : POST /checkouts (create from cart)","     started --> addressed : PUT /checkouts/{id}/address","     addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method","-    shipping_selected --> payment_pending : POST /payments/* (create intent/session)","-    payment_pending --> completed : Webhook confirms successful payment","-    payment_pending --> shipping_selected : Payment failed, customer retries","+    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method","+    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)","+    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)","+    payment_selected --> shipping_selected : Payment declined, customer retries","     started --> expired : 24-hour TTL reached","     addressed --> expired : 24-hour TTL reached","     shipping_selected --> expired : 24-hour TTL reached","-    payment_pending --> expired : 24-hour TTL reached","+    payment_selected --> expired : 24-hour TTL reached","     completed --> [*]"," ```"," "]},{"oldStart":3110,"oldLines":9,"newStart":3111,"newLines":9,"lines":[" |------|----|---------|"," | `started` | `addressed` | PUT /checkouts/{id}/address with valid address |"," | `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |","-| `shipping_selected` | `payment_pending` | POST /payments/* creates a payment intent/session |","-| `payment_pending` | `completed` | Successful payment confirmed via webhook |","-| `payment_pending` | `shipping_selected` | Payment failed, customer retries |","+| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |","+| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |","+| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |"," | `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |"," "," Applying or removing a discount does not change the checkout status but triggers totals recalculation at the current state."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"3f29ff60-e4f0-4a1f-8e49-f209806329b1"}
{"parentUuid":"9c01ed0a-f086-46d1-a753-43ec9d85b91b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017L9MBNtZq9dyfNBFf6Q1M8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013zXj4TGgC5oKs1jshrfRRh","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.","new_string":"- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":718,"cache_read_input_tokens":111955,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":718},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WSmBUzcqWuAo5WF241","type":"assistant","uuid":"a2fc09ba-4884-4001-a361-2836131466af","timestamp":"2026-02-11T15:21:11.716Z"}
{"parentUuid":"a2fc09ba-4884-4001-a361-2836131466af","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_013zXj4TGgC5oKs1jshrfRRh","toolUseID":"toolu_013zXj4TGgC5oKs1jshrfRRh","timestamp":"2026-02-11T15:21:11.722Z","uuid":"81ab268e-aee7-4704-bac2-e9b388321f6c"}
{"parentUuid":"a2fc09ba-4884-4001-a361-2836131466af","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013zXj4TGgC5oKs1jshrfRRh","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"1ca48d9f-4c7b-40c4-87b9-5c1d56a67c51","timestamp":"2026-02-11T15:21:11.721Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.","newString":"- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version.","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| *(no inbound webhook endpoints - mock PSP)* | - |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)\n    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)\n    payment_selected --> shipping_selected : Payment declined, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_selected --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |\n| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |\n| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":3125,"oldLines":7,"newStart":3125,"newLines":7,"lines":[" - **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version."," - **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy."," - **App API:** Versioned via URL path (`/api/apps/v1/`).","-- **Webhook payloads:** Include a `api_version` field in outbound webhook deliveries. Each app installation pins to a version.","+- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version."," "," Non-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"a2fc09ba-4884-4001-a361-2836131466af"}
{"parentUuid":"1ca48d9f-4c7b-40c4-87b9-5c1d56a67c51","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RCtH5M3432qiCZksVs9XFS","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me also update the checkout step detail table in 02-API-ROUTES.md. Let me also check that the `POST /checkouts/{id}/pay` endpoint section references the payment method selection step properly."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1215,"cache_read_input_tokens":112673,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1215},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WU22ECN4zM8dY9B61D","type":"assistant","uuid":"0cf112f6-bbf4-4be8-a4d3-19132f7b6687","timestamp":"2026-02-11T15:21:15.865Z"}
{"parentUuid":"0cf112f6-bbf4-4be8-a4d3-19132f7b6687","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RCtH5M3432qiCZksVs9XFS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FMrvnUvMVobpu9SLmw83Xz","name":"Grep","input":{"pattern":"payment-method|payment_method","path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","output_mode":"content"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1215,"cache_read_input_tokens":112673,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1215},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WU22ECN4zM8dY9B61D","type":"assistant","uuid":"4e6c9bc2-8617-4ef7-bb68-d98aabc82235","timestamp":"2026-02-11T15:21:16.914Z"}
{"parentUuid":"4e6c9bc2-8617-4ef7-bb68-d98aabc82235","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_01FMrvnUvMVobpu9SLmw83Xz","toolUseID":"toolu_01FMrvnUvMVobpu9SLmw83Xz","timestamp":"2026-02-11T15:21:17.189Z","uuid":"d1987938-be50-431c-96eb-2953942c933c"}
{"parentUuid":"4e6c9bc2-8617-4ef7-bb68-d98aabc82235","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FMrvnUvMVobpu9SLmw83Xz","type":"tool_result","content":"439:    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n813:  \"payment_method\": \"credit_card\",\n825:  \"payment_method\": \"paypal\"\n833:  \"payment_method\": \"bank_transfer\"\n839:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n840:| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n841:| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n842:| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n843:| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n856:    \"payment_method\": \"credit_card\",\n874:    \"payment_method\": \"bank_transfer\",\n3097:    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n3114:| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |"}]},"uuid":"f7ece9ca-8232-4f95-b22b-22ed8c8367b1","timestamp":"2026-02-11T15:21:17.188Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"439:    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n813:  \"payment_method\": \"credit_card\",\n825:  \"payment_method\": \"paypal\"\n833:  \"payment_method\": \"bank_transfer\"\n839:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n840:| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n841:| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n842:| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n843:| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n856:    \"payment_method\": \"credit_card\",\n874:    \"payment_method\": \"bank_transfer\",\n3097:    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n3114:| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |","numLines":13},"sourceToolAssistantUUID":"4e6c9bc2-8617-4ef7-bb68-d98aabc82235"}
{"parentUuid":"f7ece9ca-8232-4f95-b22b-22ed8c8367b1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_013KKPVdywGk4ot2PmV7FVFP","type":"message","role":"assistant","content":[{"type":"text","text":"Good, the 02-API-ROUTES.md is now clean. Let me also check if there's a PUT endpoint for selecting payment method that I need to add."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":820,"cache_read_input_tokens":113888,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":820},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WUREjNVS2NLxBHtbR3","type":"assistant","uuid":"dc942471-bfc1-405b-91db-163ee70591ca","timestamp":"2026-02-11T15:21:20.465Z"}
{"parentUuid":"dc942471-bfc1-405b-91db-163ee70591ca","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_013KKPVdywGk4ot2PmV7FVFP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019iykQBf8dBEAGEEJ2F2cFT","name":"Grep","input":{"pattern":"PUT.*payment|payment-method.*PUT|shipping-method","path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","output_mode":"content","-n":true}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":820,"cache_read_input_tokens":113888,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":820},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WUREjNVS2NLxBHtbR3","type":"assistant","uuid":"78872d54-3afe-4932-9542-6c24cbb10559","timestamp":"2026-02-11T15:21:21.690Z"}
{"parentUuid":"78872d54-3afe-4932-9542-6c24cbb10559","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_019iykQBf8dBEAGEEJ2F2cFT","toolUseID":"toolu_019iykQBf8dBEAGEEJ2F2cFT","timestamp":"2026-02-11T15:21:21.976Z","uuid":"e83d5f2f-fc49-4992-8e96-8791a44266c1"}
{"parentUuid":"78872d54-3afe-4932-9542-6c24cbb10559","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019iykQBf8dBEAGEEJ2F2cFT","type":"tool_result","content":"431:    C->>API: PUT /checkouts/{id}/shipping-method\n661:#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n3096:    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n3097:    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n3113:| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n3114:| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |"}]},"uuid":"922de873-d0ac-4714-8b65-5ba923f315b0","timestamp":"2026-02-11T15:21:21.975Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"431:    C->>API: PUT /checkouts/{id}/shipping-method\n661:#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n3096:    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n3097:    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n3113:| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n3114:| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |","numLines":6},"sourceToolAssistantUUID":"78872d54-3afe-4932-9542-6c24cbb10559"}
{"parentUuid":"922de873-d0ac-4714-8b65-5ba923f315b0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01LRUQULbBZ6aDZ8RHRZWKWX","type":"message","role":"assistant","content":[{"type":"text","text":"I need to add a `PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method` endpoint. Let me read the area around the shipping-method endpoint to add it after."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":337,"cache_read_input_tokens":114708,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":337},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WUmhJ6JboV2WuQAjg5","type":"assistant","uuid":"b756212d-b88c-4615-881a-6a5951ae4213","timestamp":"2026-02-11T15:21:25.281Z"}
{"parentUuid":"b756212d-b88c-4615-881a-6a5951ae4213","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01LRUQULbBZ6aDZ8RHRZWKWX","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GdVt7Tpsx8cDbi1bp8B1mp","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":661,"limit":150}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":337,"cache_read_input_tokens":114708,"output_tokens":157,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":337,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WUmhJ6JboV2WuQAjg5","type":"assistant","uuid":"61a9d380-f48e-4df1-b0ac-303c3d17b61e","timestamp":"2026-02-11T15:21:26.105Z"}
{"parentUuid":"61a9d380-f48e-4df1-b0ac-303c3d17b61e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01GdVt7Tpsx8cDbi1bp8B1mp","toolUseID":"toolu_01GdVt7Tpsx8cDbi1bp8B1mp","timestamp":"2026-02-11T15:21:26.110Z","uuid":"c001bad7-d9c9-435e-82dc-b32fe3011752"}
{"parentUuid":"61a9d380-f48e-4df1-b0ac-303c3d17b61e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GdVt7Tpsx8cDbi1bp8B1mp","type":"tool_result","content":"   661→#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n   662→\n   663→Select a shipping method for the checkout.\n   664→\n   665→- **Authentication:** None\n   666→- **Rate limit:** `checkout` (10/min per session)\n   667→\n   668→**Request body:**\n   669→\n   670→```json\n   671→{\n   672→  \"shipping_method_id\": 1\n   673→}\n   674→```\n   675→\n   676→| Field | Type | Required | Validation |\n   677→|-------|------|----------|------------|\n   678→| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n   679→\n   680→**Success response:** `200 OK`\n   681→\n   682→Returns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n   683→\n   684→```json\n   685→{\n   686→  \"id\": 7,\n   687→  \"status\": \"shipping_selected\",\n   688→  \"shipping_method_id\": 1,\n   689→  \"totals\": {\n   690→    \"subtotal\": 5000,\n   691→    \"discount\": 0,\n   692→    \"shipping\": 500,\n   693→    \"tax\": 1045,\n   694→    \"total\": 6545,\n   695→    \"currency\": \"EUR\"\n   696→  },\n   697→  \"tax_provider_snapshot_json\": {\n   698→    \"provider\": \"manual\",\n   699→    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n   700→    \"lines\": [\n   701→      {\n   702→        \"variant_id\": 101,\n   703→        \"tax_amount\": 950,\n   704→        \"rate\": 1900,\n   705→        \"jurisdiction\": \"DE\"\n   706→      }\n   707→    ],\n   708→    \"shipping_tax_amount\": 95,\n   709→    \"shipping_tax_rate\": 1900\n   710→  }\n   711→}\n   712→```\n   713→\n   714→**Error responses:**\n   715→- `404` - Checkout not found\n   716→- `410` - Checkout expired\n   717→- `422` - Invalid shipping method (not available for this address/checkout)\n   718→\n   719→---\n   720→\n   721→#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n   722→\n   723→Apply a discount code to the checkout.\n   724→\n   725→- **Authentication:** None\n   726→- **Rate limit:** `checkout` (10/min per session)\n   727→\n   728→**Request body:**\n   729→\n   730→```json\n   731→{\n   732→  \"code\": \"WELCOME10\"\n   733→}\n   734→```\n   735→\n   736→| Field | Type | Required | Validation |\n   737→|-------|------|----------|------------|\n   738→| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n   739→\n   740→**Success response:** `200 OK`\n   741→\n   742→```json\n   743→{\n   744→  \"id\": 7,\n   745→  \"status\": \"shipping_selected\",\n   746→  \"discount_code\": \"WELCOME10\",\n   747→  \"totals\": {\n   748→    \"subtotal\": 5000,\n   749→    \"discount\": 500,\n   750→    \"shipping\": 500,\n   751→    \"tax\": 950,\n   752→    \"total\": 5950,\n   753→    \"currency\": \"EUR\"\n   754→  },\n   755→  \"applied_discounts\": [\n   756→    {\n   757→      \"code\": \"WELCOME10\",\n   758→      \"type\": \"percent\",\n   759→      \"value_amount\": 10,\n   760→      \"applied_amount\": 500,\n   761→      \"description\": \"10% off your first order\"\n   762→    }\n   763→  ]\n   764→}\n   765→```\n   766→\n   767→**Error responses:**\n   768→- `400` - Discount code expired (error code: `discount_expired`)\n   769→- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n   770→- `404` - Checkout not found\n   771→- `410` - Checkout expired\n   772→- `422` - Invalid code (not found, does not apply to these items)\n   773→\n   774→```json\n   775→{\n   776→  \"message\": \"This discount code has expired.\",\n   777→  \"error_code\": \"discount_expired\"\n   778→}\n   779→```\n   780→\n   781→---\n   782→\n   783→#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n   784→\n   785→Remove the applied discount code from the checkout.\n   786→\n   787→- **Authentication:** None\n   788→- **Rate limit:** `checkout` (10/min per session)\n   789→\n   790→**Success response:** `200 OK`\n   791→\n   792→Returns the full checkout object with discount removed and totals recalculated.\n   793→\n   794→**Error responses:**\n   795→- `404` - Checkout not found or no discount applied\n   796→- `410` - Checkout expired\n   797→\n   798→---\n   799→\n   800→### 2.3 Payment Endpoint\n   801→\n   802→#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n   803→\n   804→Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n   805→\n   806→- **Authentication:** None\n   807→- **Rate limit:** `checkout` (10/min per session)\n   808→\n   809→**Request body (Credit Card):**\n   810→"}]},"uuid":"9ef4f483-d1a0-42f1-9261-9bf3a2890e9d","timestamp":"2026-02-11T15:21:26.109Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n","numLines":150,"startLine":661,"totalLines":3195}},"sourceToolAssistantUUID":"61a9d380-f48e-4df1-b0ac-303c3d17b61e"}
{"parentUuid":"9ef4f483-d1a0-42f1-9261-9bf3a2890e9d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017XiaSAuuAidTm51fkF1aGA","type":"message","role":"assistant","content":[{"type":"text","text":"I need to add the payment-method selection endpoint between the shipping-method endpoint and the apply-discount endpoint. Let me insert it."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1732,"cache_read_input_tokens":115045,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1732},"output_tokens":5,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WV5RPxY75en1o7Z5gg","type":"assistant","uuid":"7ac8356b-2ec7-4013-94df-b9625929ea36","timestamp":"2026-02-11T15:21:29.176Z"}
{"parentUuid":"7ac8356b-2ec7-4013-94df-b9625929ea36","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017XiaSAuuAidTm51fkF1aGA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017XkrXwe3FxP6czUKj4ahHC","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount","new_string":"---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method\n\nSelect a payment method for the checkout. This step records the chosen method without processing payment.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"payment_method\": \"credit_card\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `payment_selected`.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"payment_selected\",\n  \"payment_method\": \"credit_card\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid payment method or checkout not in `shipping_selected` state\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1732,"cache_read_input_tokens":115045,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1732},"output_tokens":5,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WV5RPxY75en1o7Z5gg","type":"assistant","uuid":"9e767d16-7d1e-4ccb-95dd-bf0ffa301290","timestamp":"2026-02-11T15:21:33.833Z"}
{"parentUuid":"9e767d16-7d1e-4ccb-95dd-bf0ffa301290","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_017XkrXwe3FxP6czUKj4ahHC","toolUseID":"toolu_017XkrXwe3FxP6czUKj4ahHC","timestamp":"2026-02-11T15:21:33.839Z","uuid":"35165d79-0830-4922-9da3-5af3fc16f2ee"}
{"parentUuid":"9e767d16-7d1e-4ccb-95dd-bf0ffa301290","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017XkrXwe3FxP6czUKj4ahHC","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"236e0be5-c6dd-4db5-8327-3c046462f9ee","timestamp":"2026-02-11T15:21:33.838Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount","newString":"---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method\n\nSelect a payment method for the checkout. This step records the chosen method without processing payment.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"payment_method\": \"credit_card\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `payment_selected`.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"payment_selected\",\n  \"payment_method\": \"credit_card\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid payment method or checkout not in `shipping_selected` state\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| *(no inbound webhook endpoints - mock PSP)* | - |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)\n    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)\n    payment_selected --> shipping_selected : Payment declined, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_selected --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |\n| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |\n| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":718,"oldLines":6,"newStart":718,"newLines":52,"lines":[" "," ---"," ","+#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method","+","+Select a payment method for the checkout. This step records the chosen method without processing payment.","+","+- **Authentication:** None","+- **Rate limit:** `checkout` (10/min per session)","+","+**Request body:**","+","+```json","+{","+  \"payment_method\": \"credit_card\"","+}","+```","+","+| Field | Type | Required | Validation |","+|-------|------|----------|------------|","+| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |","+","+**Success response:** `200 OK`","+","+Returns the full checkout object. Status transitions to `payment_selected`.","+","+```json","+{","+  \"id\": 7,","+  \"status\": \"payment_selected\",","+  \"payment_method\": \"credit_card\",","+  \"totals\": {","+    \"subtotal\": 5000,","+    \"discount\": 0,","+    \"shipping\": 500,","+    \"tax\": 1045,","+    \"total\": 6545,","+    \"currency\": \"EUR\"","+  }","+}","+```","+","+**Error responses:**","+- `404` - Checkout not found","+- `410` - Checkout expired","+- `422` - Invalid payment method or checkout not in `shipping_selected` state","+","+---","+"," #### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount"," "," Apply a discount code to the checkout."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"9e767d16-7d1e-4ccb-95dd-bf0ffa301290"}
{"parentUuid":"236e0be5-c6dd-4db5-8327-3c046462f9ee","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01W1NrDd3y5rFdPPSqSgGxLf","type":"message","role":"assistant","content":[{"type":"text","text":"Task #3 is now complete. Let me mark it and start on the remaining tasks."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":563,"cache_read_input_tokens":116777,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":563},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WVeah692D28RgzmkFf","type":"assistant","uuid":"89a300ab-9793-49e2-96d7-e85a006d5369","timestamp":"2026-02-11T15:21:37.027Z"}
{"parentUuid":"89a300ab-9793-49e2-96d7-e85a006d5369","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01W1NrDd3y5rFdPPSqSgGxLf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0142icPswkNnagfuJt7KCmbQ","name":"TaskUpdate","input":{"taskId":"3","status":"completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":563,"cache_read_input_tokens":116777,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":563},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WVeah692D28RgzmkFf","type":"assistant","uuid":"c779e32d-27c9-41b3-b79c-2496e5e472be","timestamp":"2026-02-11T15:21:37.588Z"}
{"parentUuid":"c779e32d-27c9-41b3-b79c-2496e5e472be","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0142icPswkNnagfuJt7KCmbQ","type":"tool_result","content":"Updated task #3 status"}]},"uuid":"a73ba29e-2c08-4a8c-b405-1e51937c0a8f","timestamp":"2026-02-11T15:21:37.594Z","toolUseResult":{"success":true,"taskId":"3","updatedFields":["status"],"statusChange":{"from":"in_progress","to":"completed"}},"sourceToolAssistantUUID":"c779e32d-27c9-41b3-b79c-2496e5e472be"}
{"parentUuid":"a73ba29e-2c08-4a8c-b405-1e51937c0a8f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01W1NrDd3y5rFdPPSqSgGxLf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019dive3U233NoX1V2MY7J8o","name":"TaskUpdate","input":{"taskId":"4","status":"in_progress"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":563,"cache_read_input_tokens":116777,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":563},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WVeah692D28RgzmkFf","type":"assistant","uuid":"02f2572d-50cc-4b62-94ac-83cf6b2c0c5b","timestamp":"2026-02-11T15:21:37.820Z"}
{"parentUuid":"02f2572d-50cc-4b62-94ac-83cf6b2c0c5b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019dive3U233NoX1V2MY7J8o","type":"tool_result","content":"Updated task #4 status"}]},"uuid":"c8b11253-0214-4e6e-a2ac-4c986f912048","timestamp":"2026-02-11T15:21:37.821Z","toolUseResult":{"success":true,"taskId":"4","updatedFields":["status"],"statusChange":{"from":"pending","to":"in_progress"}},"sourceToolAssistantUUID":"02f2572d-50cc-4b62-94ac-83cf6b2c0c5b"}
{"parentUuid":"c8b11253-0214-4e6e-a2ac-4c986f912048","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Q7Kvpx5GfCW3vrjAMNrRYa","type":"message","role":"assistant","content":[{"type":"text","text":"Now editing **03-ADMIN-UI.md** (Task #4). I need to:\n1. Update Payment Details section to show payment method instead of Stripe/PayPal\n2. Add \"Confirm Payment\" button for bank_transfer orders\n3. Add fulfillment guard callout\n4. Add \"Mark as shipped\" / \"Mark as delivered\" buttons on fulfillment cards\n5. Update component requirements"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":218,"cache_read_input_tokens":117340,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":218},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WVwcsTUeWQgd9m4PPw","type":"assistant","uuid":"7ed8bab0-ffc2-4d1b-9177-99cb2e6df113","timestamp":"2026-02-11T15:21:41.978Z"}
{"type":"file-history-snapshot","messageId":"dd83137c-455c-49bf-8957-6924aa344e66","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v1","version":1,"backupTime":"2026-02-11T15:14:23.948Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v1","version":1,"backupTime":"2026-02-11T15:16:45.432Z"},"specs/03-ADMIN-UI.md":{"backupFileName":"a791bee027d52553@v1","version":1,"backupTime":"2026-02-11T15:21:51.319Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"7ed8bab0-ffc2-4d1b-9177-99cb2e6df113","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Q7Kvpx5GfCW3vrjAMNrRYa","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Wvo8qWPbGC7NaevLviHFDz","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","old_string":"| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `createFulfillment()` | Creates a fulfillment record for selected lines |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |","new_string":"| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `confirmPayment()` | Confirms bank transfer payment received (transitions financial_status to `paid`, commits inventory, dispatches `OrderPaid`) |\n| `createFulfillment()` | Creates a fulfillment record for selected lines (enforces fulfillment guard) |\n| `markAsShipped(int $fulfillmentId)` | Transitions fulfillment to `shipped`, sets `shipped_at` |\n| `markAsDelivered(int $fulfillmentId)` | Transitions fulfillment to `delivered`, sets `delivered_at` |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":218,"cache_read_input_tokens":117340,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":218},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WVwcsTUeWQgd9m4PPw","type":"assistant","uuid":"dd83137c-455c-49bf-8957-6924aa344e66","timestamp":"2026-02-11T15:21:51.316Z"}
{"parentUuid":"dd83137c-455c-49bf-8957-6924aa344e66","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Wvo8qWPbGC7NaevLviHFDz","toolUseID":"toolu_01Wvo8qWPbGC7NaevLviHFDz","timestamp":"2026-02-11T15:21:51.322Z","uuid":"b4ece904-a787-4a85-b4d5-64eeb0f569c4"}
{"parentUuid":"dd83137c-455c-49bf-8957-6924aa344e66","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Wvo8qWPbGC7NaevLviHFDz","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md has been updated successfully."}]},"uuid":"a6895db4-1bc1-489b-9ce7-a09f9381da76","timestamp":"2026-02-11T15:21:51.322Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","oldString":"| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `createFulfillment()` | Creates a fulfillment record for selected lines |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |","newString":"| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `confirmPayment()` | Confirms bank transfer payment received (transitions financial_status to `paid`, commits inventory, dispatches `OrderPaid`) |\n| `createFulfillment()` | Creates a fulfillment record for selected lines (enforces fulfillment guard) |\n| `markAsShipped(int $fulfillmentId)` | Transitions fulfillment to `shipped`, sets `shipped_at` |\n| `markAsDelivered(int $fulfillmentId)` | Transitions fulfillment to `delivered`, sets `delivered_at` |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |","originalFile":"# 03 - Admin Panel UI/UX Specification\n\nThis document defines every view, component, and interaction for the Shopify-clone admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.\n\n**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip\n\nAnything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.\n\n---\n\n## 1. Admin Layout Shell\n\nThe admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.\n\n### 1.1 Route Prefix\n\nAll admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.\n\n### 1.2 Sidebar Navigation\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\Sidebar`\n\n**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |\n| `currentRoute` | `string` | The current route name for active highlighting |\n\n| Action | Behavior |\n|--------|----------|\n| `toggle()` | Toggles the `collapsed` state |\n\n#### Wireframe\n\n```\n+-------------------------------+\n|  [BRAND LOGO]  Platform Name  |\n|-------------------------------|\n|  [separator]                  |\n|                               |\n|  [chart-bar]    Dashboard     |\n|                               |\n|  PRODUCTS                     |\n|  [cube]         Products      |\n|  [rect-stack]   Collections   |\n|  [archive-box]  Inventory     |\n|                               |\n|  [separator]                  |\n|  ORDERS                       |\n|  [shopping-bag] Orders        |\n|                               |\n|  [separator]                  |\n|  CUSTOMERS                    |\n|  [users]        Customers     |\n|                               |\n|  [separator]                  |\n|  DISCOUNTS                    |\n|  [tag]          Discounts     |\n|                               |\n|  [separator]                  |\n|  CONTENT                      |\n|  [doc-text]     Pages         |\n|  [bars-3]       Navigation    |\n|  [paint-brush]  Themes        |\n|                               |\n|  [separator]                  |\n|  [chart-pie]    Analytics     |\n|  [cog-6-tooth]  Settings      |\n|  [squares-2x2]  Apps          |\n|  [code-bracket] Developers    |\n+-------------------------------+\n```\n\n#### Navigation Structure\n\n```\nDashboard                        -> admin.dashboard\nProducts (group label)\n  Products                       -> admin.products.index\n  Collections                    -> admin.collections.index\n  Inventory                      -> admin.inventory.index\nOrders\n  Orders                         -> admin.orders.index\nCustomers\n  Customers                      -> admin.customers.index\nDiscounts\n  Discounts                      -> admin.discounts.index\nContent (group label)\n  Pages                          -> admin.pages.index\n  Navigation                     -> admin.navigation.index\n  Themes                         -> admin.themes.index\nAnalytics                        -> admin.analytics.index\nSettings                         -> admin.settings.index\nApps                             -> admin.apps.index\nDevelopers                       -> admin.developers.index\n```\n\nEach nav item displays an icon (via Flux icon component) and a text label. The active item receives a highlighted background and bold text. All navigation links use Livewire SPA-style page transitions. Groups are visually separated with Flux separator components.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Platform logo and name | `brand` |\n| Navigation icons | `icon` |\n| Group dividers | `separator` |\n\n#### Responsive Behavior\n\n- **Desktop (lg and above):** Sidebar is always visible, fixed left, 256px wide. Main content has a left margin of 256px.\n- **Mobile (below lg):** Sidebar is hidden off-screen. A hamburger button (icon: bars-3) appears in the top bar. When user clicks it, the sidebar slides in as an overlay with a semi-transparent backdrop. Clicking the backdrop or a nav item closes it.\n\n### 1.3 Top Bar\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\TopBar`\n\n**Blade view:** `resources/views/livewire/admin/layout/top-bar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `currentStoreName` | `string` | Name of the active store |\n| `stores` | `Collection` | List of stores the user has access to |\n| `unreadNotificationCount` | `int` | Count of unread notifications |\n\n| Action | Behavior |\n|--------|----------|\n| `switchStore(string $storeId)` | Switches active store in session, redirects to dashboard |\n\n#### Wireframe\n\n```\n+--------------------------------------------------------------------+\n| [hamburger*]  [Store Name v]                  [bell(3)]  [avatar v] |\n+--------------------------------------------------------------------+\n  * hamburger visible on mobile only\n\n  Store Name dropdown:\n    - Store A\n    - Store B\n    - Store C\n\n  Avatar dropdown:\n    - Settings (links to /admin/settings)\n    - [separator]\n    - Log out (POST /logout with CSRF)\n```\n\n#### Interaction Flows\n\n- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.\n- Notification bell shows an unread count badge (positioned absolutely over the icon) when `unreadNotificationCount > 0`.\n- Profile dropdown is triggered by a Flux profile component showing the user's avatar and name.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Store selector | `dropdown` |\n| Hamburger (mobile) | `button` (icon: bars-3) |\n| Notification bell | `button` (icon: bell, variant: ghost) |\n| Notification count | `badge` |\n| User profile trigger | `profile` |\n| Profile menu | `dropdown` |\n| Menu divider | `separator` |\n\n### 1.4 Main Content Area\n\nThe main content area sits to the right of the sidebar and below the top bar. Every page renders within this area.\n\n**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always \"Home\" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > \"Blue T-Shirt\".\n\n**Layout wrapper:** The content area has consistent padding (larger on desktop, smaller on mobile) and a max-width container centered horizontally.\n\n### 1.5 Toast Notifications\n\nA global toast notification system lives in the layout shell. It uses Alpine.js to listen for Livewire dispatched events.\n\n| Property | Value |\n|----------|-------|\n| Event name | `toast` |\n| Payload | `{ type: 'success' / 'error' / 'info', message: string }` |\n| Position | Top-right corner |\n| Auto-dismiss | 5 seconds |\n| Stacking | Supports multiple toasts |\n| Styling | Rounded card, shadow, colored left border (green=success, red=error, blue=info) |\n\nComponents dispatch toasts after successful actions (e.g., \"Product saved successfully.\") or on errors (\"Something went wrong. Please try again.\").\n\n### 1.6 Dark Mode\n\nThe admin supports dark mode via Tailwind's `dark:` variant. The mode follows the user's system preference by default. A toggle can be added in the profile dropdown later. All components and custom markup must include dark mode variants.\n\nDark mode preference is persisted in `localStorage` under the key `theme` with values `light` or `dark`. On page load, the stored preference is applied before first paint to avoid flash of incorrect theme. If no preference is stored, the system preference (`prefers-color-scheme`) is used as default.\n\n---\n\n## 2. Dashboard\n\n**Route:** `GET /admin`\n\n**Livewire component:** `App\\Livewire\\Admin\\Dashboard`\n\n**Blade view:** `resources/views/livewire/admin/dashboard.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Active date range preset: `today`, `last_7_days`, `last_30_days`, `custom` |\n| `customStartDate` | `?string` | Custom range start (Y-m-d) |\n| `customEndDate` | `?string` | Custom range end (Y-m-d) |\n| `totalSales` | `int` | Total sales amount in minor units |\n| `ordersCount` | `int` | Number of orders in range |\n| `averageOrderValue` | `int` | Average order value in minor units |\n| `visitorsCount` | `int` | Unique visitors in range |\n| `salesChange` | `float` | Percentage change vs previous period |\n| `ordersChange` | `float` | Percentage change vs previous period |\n| `aovChange` | `float` | Percentage change vs previous period |\n| `visitorsChange` | `float` | Percentage change vs previous period |\n| `ordersChartData` | `array` | Array of `{date, count}` for last 30 days |\n| `topProducts` | `array` | Top 5 products: `{title, units_sold, revenue}` |\n| `funnelData` | `array` | Funnel steps: `{visits, add_to_cart, checkout_started, checkout_completed}` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Re-fetches all KPI data for the selected range |\n| `loadKpis()` | Queries aggregated order/analytics data |\n| `loadChart()` | Queries daily order counts for chart |\n| `loadTopProducts()` | Queries top 5 products by revenue |\n| `loadFunnel()` | Queries conversion funnel data |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `formattedTotalSales` | Formatted currency string |\n| `formattedAov` | Formatted currency string |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Dashboard                           [Today v] (date range picker) |\n+------------------------------------------------------------------+\n|                                                                    |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|  | Total Sales |  | Orders      |  | Avg Order   |  | Visitors |  |\n|  | $12,345     |  | 156         |  | $79.13      |  | 2,340    |  |\n|  | [+12.3%] ^  |  | [+8.1%] ^  |  | [-2.1%] v  |  | [+5%] ^  |  |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|                                                                    |\n|  +--------------------------------------------------------------+  |\n|  | Orders over time                                              |  |\n|  |  [line/bar chart - daily order counts for selected range]     |  |\n|  +--------------------------------------------------------------+  |\n|                                                                    |\n|  +------------------------------+  +----------------------------+  |\n|  | Top products                 |  | Conversion funnel          |  |\n|  | Product Title | Sold | Rev   |  | Visits       [========] N |  |\n|  | Blue Shirt    | 45   | $900  |  | Add to Cart  [======]   N |  |\n|  | Red Hat       | 32   | $640  |  | Checkout     [====]     N |  |\n|  | Sneakers      | 28   | $2800 |  | Completed    [==]       N |  |\n|  | ...           |      |       |  |                            |  |\n|  +------------------------------+  +----------------------------+  |\n+--------------------------------------------------------------------+\n```\n\n### Layout Description\n\n**Page heading:** \"Dashboard\" displayed as a Flux heading (size xl).\n\n**Date range filter:** Positioned top-right. A Flux dropdown triggered by a button showing the current range label and a calendar icon. Options: Today, Last 7 days, Last 30 days, Custom range (reveals two date input fields when selected).\n\n### KPI Tiles Row\n\nA responsive 4-column grid (1 column on mobile, 2 on sm, 4 on lg).\n\nEach tile is a card containing:\n- A muted-color text label (e.g., \"Total Sales\")\n- A large heading with the formatted number value\n- A row with a Flux badge showing percentage change (green if positive, red if negative) and a directional arrow icon\n\nEach tile shows a loading state (reduced opacity) while data refreshes.\n\n### Orders Chart\n\nA card below the KPI tiles containing:\n- A Flux heading (size lg): \"Orders over time\"\n- A Chart.js canvas (line or bar chart) showing daily order counts\n- Chart.js initialized via Alpine.js; data passed from Livewire\n- A spinner overlay shown during chart data loading\n\n### Top Products Table\n\nA card containing:\n- A Flux heading (size lg): \"Top products\"\n- A plain table with columns: Product Title, Units Sold, Revenue\n- Maximum 5 rows\n- Empty state text: \"No sales data for this period.\"\n\n### Conversion Funnel\n\nA card containing:\n- A Flux heading (size lg): \"Conversion funnel\"\n- Horizontal bar visualization using Tailwind utility widths\n- Each step: label on the left, proportional-width bar on the right, count displayed\n- Steps: Visits, Add to Cart, Checkout Started, Checkout Completed\n- Bars use decreasing widths and progressively darker colors\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` (size xl) |\n| Section titles | `heading` (size lg) |\n| Date range selector | `dropdown`, `button`, `icon` |\n| Date inputs (custom range) | `input` (type date) |\n| KPI labels | `text` |\n| KPI values | `heading` (size xl) |\n| Trend badges | `badge` |\n| Trend arrows | `icon` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | KPI tiles show reduced opacity; chart shows spinner overlay |\n| Empty | \"No sales data for this period.\" in top products; funnel bars at zero width |\n| Populated | All tiles, chart, table, and funnel render with data |\n| Error | Toast notification with error message |\n\n---\n\n## 3. Products List\n\n**Route:** `GET /admin/products`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Index`\n\n**Blade view:** `resources/views/livewire/admin/products/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query, live-debounced at 300ms |\n| `statusFilter` | `string` | Status filter: `all`, `draft`, `active`, `archived` |\n| `typeFilter` | `string` | Product type filter |\n| `selectedIds` | `array<int>` | Array of selected product IDs for bulk actions |\n| `selectAll` | `bool` | Whether all visible rows are selected |\n| `sortField` | `string` | Column to sort by (default: `updated_at`) |\n| `sortDirection` | `string` | `asc` or `desc` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `updatedTypeFilter()` | Resets pagination |\n| `bulkArchive()` | Archives all selected products |\n| `bulkDelete()` | Soft-deletes selected products (with confirmation) |\n| `bulkSetActive()` | Sets selected products to active status |\n| `sortBy(string $field)` | Toggles sort direction or changes sort field |\n| `toggleSelectAll()` | Selects or deselects all visible product IDs |\n| `confirmBulkDelete()` | Opens delete confirmation modal |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |\n| `productTypes` | Distinct product types for the type filter dropdown |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Products                                    [+ Add product]       |\n+------------------------------------------------------------------+\n| [search icon] Search products...   [Status: All v] [Type: All v] |\n+------------------------------------------------------------------+\n| (shown when items selected)                                       |\n| \"3 products selected\"  [Set Active]  [Archive]  [Delete]         |\n+------------------------------------------------------------------+\n| [x] | IMG | Title ^     | Status  | Inventory | Type | Vendor | Updated ^  |\n|-----|-----|-------------|---------|-----------|------|--------|-----------|\n| [ ] | img | Blue Shirt  | Active  | 45        | Tops | Nike   | 2h ago    |\n| [ ] | img | Red Hat     | Draft   | 12        | Hats | Adidas | 1d ago    |\n| [ ] | img | Sneakers    | Active  | 0         | Shoes| Nike   | 3d ago    |\n| ...                                                              |\n+------------------------------------------------------------------+\n| < 1 2 3 ... 10 >  (pagination)                                  |\n+------------------------------------------------------------------+\n```\n\n### Table Column Definitions\n\n| Column | Sortable | Content |\n|--------|----------|---------|\n| Select | No | Checkbox for bulk selection |\n| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |\n| Title | Yes | Product title as a link to the edit page (uses Livewire navigate) |\n| Status | No | Flux badge: draft=zinc/gray, active=green, archived=red |\n| Inventory | Yes | Sum of `inventory_items.quantity_on_hand` across all variants |\n| Type | No | Product type text or \"-\" |\n| Vendor | No | Vendor name text or \"-\" |\n| Updated | Yes (default) | Relative timestamp (e.g., \"2h ago\") |\n\n### Bulk Action Bar\n\nConditionally shown when `count($selectedIds) > 0`. Contains:\n- Text showing \"X products selected\"\n- \"Set Active\" button (ghost variant)\n- \"Archive\" button (ghost variant)\n- \"Delete\" button (danger variant) - opens confirmation modal\n\n### Empty State\n\nWhen no products exist (not filtered, genuinely empty):\n- Centered container with a large muted placeholder icon (cube)\n- Heading: \"Add your first product\"\n- Text: \"Start building your catalog by adding products.\"\n- Primary button: \"Add product\" linking to the create page\n\n### Delete Confirmation Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-bulk-delete` |\n| Max width | md |\n| Heading | \"Delete products?\" |\n| Body text | \"This will archive X product(s). Products with orders cannot be permanently deleted.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `bulkDelete` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Add product button | `button` (variant: primary) |\n| Search field | `input` (icon: magnifying-glass) |\n| Status filter | `select` |\n| Type filter | `select` |\n| Bulk action buttons | `button` (ghost/danger variants) |\n| Selected count text | `text` |\n| Row checkbox | `checkbox` |\n| Status badges | `badge` |\n| Cell text | `text` |\n| Delete confirmation | `modal` |\n| Pagination | Laravel default Tailwind pagination |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | Table body shows reduced opacity |\n| Empty (no products) | Empty state with illustration and CTA |\n| Empty (filtered) | Table with \"No products match your filters\" message |\n| Populated | Table with data rows and pagination |\n\n---\n\n## 4. Product Create/Edit\n\n**Route:** `GET /admin/products/create` and `GET /admin/products/{product}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Form`\n\n**Blade view:** `resources/views/livewire/admin/products/form.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `product` | `?Product` | The product being edited, null for create |\n| `title` | `string` | Product title |\n| `descriptionHtml` | `string` | Product description (HTML) |\n| `status` | `string` | `draft`, `active`, `archived` |\n| `vendor` | `string` | Vendor name |\n| `productType` | `string` | Product type string |\n| `tags` | `string` | Comma-separated tags |\n| `handle` | `string` | URL handle/slug |\n| `publishedAt` | `?string` | Publish date (Y-m-d H:i) |\n| `collectionIds` | `array<int>` | Selected collection IDs |\n| `options` | `array` | Options array: `[{name: string, values: string[]}]` |\n| `variants` | `array` | Variants array: `[{sku, price, compareAtPrice, quantity, requiresShipping, optionValues}]` |\n| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |\n| `newMedia` | `array` | Temporary uploaded files |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(?Product $product)` | Loads product data into properties or sets defaults for create |\n| `save()` | Validates and saves/creates the product, variants, options, and media |\n| `addOption()` | Adds a new empty option `{name: '', values: []}` |\n| `removeOption(int $index)` | Removes an option and regenerates variants |\n| `addOptionValue(int $optionIndex)` | Adds a value to an option |\n| `removeOptionValue(int $optionIndex, int $valueIndex)` | Removes a value |\n| `generateVariants()` | Generates variant combinations from current options |\n| `uploadMedia()` | Handles file upload via Livewire temporary upload |\n| `removeMedia(int $mediaId)` | Removes a media item |\n| `reorderMedia(array $order)` | Updates media positions |\n| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |\n| `deleteProduct()` | Archives the product (with confirmation modal) |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `availableCollections` | All collections for the current store |\n| `isEditing` | `bool` - whether editing or creating |\n\n### Validation Rules\n\n| Field | Rules |\n|-------|-------|\n| `title` | Required, string, max 255 |\n| `descriptionHtml` | Nullable, string, max 65535 |\n| `status` | Required, one of: draft, active, archived |\n| `vendor` | Nullable, string, max 255 |\n| `productType` | Nullable, string, max 255 |\n| `tags` | Nullable, string |\n| `handle` | Required, string, max 255, unique (excluding current product) |\n| `variants.*.sku` | Nullable, string, max 255 |\n| `variants.*.price` | Required, integer, min 0 |\n| `variants.*.compareAtPrice` | Nullable, integer, min 0 |\n| `variants.*.quantity` | Required, integer, min 0 |\n\n### Two-Column Layout Wireframe\n\n```\n+------------------------------------------+-------------------+\n|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|\n+------------------------------------------+-------------------+\n|                                          |                   |\n|  TITLE                                   |  STATUS CARD      |\n|  +------------------------------------+  |  Status: [Draft v]|\n|  | [Short Sleeve T-Shirt           ]  |  |                   |\n|  +------------------------------------+  |  PUBLISHING CARD  |\n|                                          |  Published at:    |\n|  DESCRIPTION                             |  [datetime input]  |\n|  +------------------------------------+  |                   |\n|  | Describe your product...           |  |  ORGANIZATION     |\n|  |                                    |  |  Vendor:          |\n|  |                                    |  |  [Nike          ] |\n|  |                                    |  |  Product type:    |\n|  +------------------------------------+  |  [T-Shirts      ] |\n|                                          |  Tags:            |\n|  MEDIA                                   |  [summer, cotton ] |\n|  +------------------------------------+  |  (comma-separated)|\n|  | +--+ +--+ +--+ +--+               |  |                   |\n|  | |  | |  | |  | |  |  (thumbnails) |  |  COLLECTIONS      |\n|  | +--+ +--+ +--+ +--+               |  |  [x] Summer Sale  |\n|  |                                    |  |  [ ] Winter       |\n|  | [--- Drag & drop or click ---]    |  |  [x] New Arrivals |\n|  +------------------------------------+  |                   |\n|                                          |                   |\n|  VARIANTS                                |                   |\n|  Option name: [Size    ]                 |                   |\n|  Values:      [S, M, L, XL         ]    |                   |\n|  [trash]                                 |                   |\n|                                          |                   |\n|  [+ Add another option]                  |                   |\n|                                          |                   |\n|  Variant | SKU    | Price | Compare | Qty | Ship |          |\n|  S / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|  M / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|  L / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|                                          |                   |\n|  SEO (collapsible)                       |                   |\n|  > Search engine listing                 |                   |\n|    URL handle: [short-sleeve-t-shirt]    |                   |\n+------------------------------------------+-------------------+\n\n+==================================================================+\n| STICKY SAVE BAR (fixed bottom)                                    |\n|                                      [Discard]  [Save]           |\n+==================================================================+\n```\n\n### Left Column Sections\n\n#### Title Input\n- Flux field with label \"Title\"\n- Flux input, placeholder \"Short Sleeve T-Shirt\"\n- Flux error display for validation\n\n#### Description\n- Flux field with label \"Description\"\n- Flux textarea, 8 rows, placeholder \"Describe your product...\"\n- Future enhancement: WYSIWYG toolbar via Alpine.js; baseline is plain textarea\n\n#### Media Section\nA card with heading \"Media\" (size md).\n\n**Upload area:** A dashed-border drop zone with upload icon and text \"Drag and drop images or click to upload\". A hidden file input (multiple, accept images) triggered by clicking the zone.\n\n**Upload progress:** A progress bar shown during file upload.\n\n**Media grid:** A 4-column grid (responsive: 2 on small, 3 on sm, 4 on lg) of thumbnails:\n- Each item: square aspect ratio, object-cover, rounded\n- On hover overlay shows: delete button (X icon, top-right), alt text edit button (pencil icon, bottom-left)\n- Drag-to-reorder supported via Livewire sortable or Alpine.js Sortable\n\n#### Variants Section\nA card with heading \"Variants\" (size md).\n\n**Options builder:** For each option, a row containing:\n- \"Option name\" input field (placeholder: \"Size\")\n- \"Values\" input field (placeholder: \"S, M, L, XL\" - comma-separated)\n- Trash button to remove the option\n\nBelow options: \"Add another option\" ghost button with plus icon.\n\nWhen options change, `generateVariants()` is called automatically via lifecycle hook.\n\n**Variants table:** Shown when variants exist. Scrollable table with inline-editable fields:\n\n| Column | Input Type | Description |\n|--------|-----------|-------------|\n| Variant | Read-only text | Combination name, e.g. \"S / Red\" |\n| SKU | Text input (small) | Stock keeping unit |\n| Price | Number input (small) | Price in major units |\n| Compare at price | Number input (small) | Original price for sale display |\n| Quantity | Number input (small) | On-hand inventory |\n| Requires shipping | Checkbox | Whether variant needs shipping |\n\n#### SEO Section\nA collapsible section (collapsed by default, expands with animation):\n- Toggle button: chevron icon + \"Search engine listing\" text\n- When expanded: URL handle input field with placeholder \"short-sleeve-t-shirt\" and validation error display\n\n### Right Column Sections\n\n#### Status Card\n- Flux field with label \"Status\"\n- Flux select with options: Draft, Active, Archived\n\n#### Publishing Card\n- Flux field with label \"Published at\"\n- Datetime-local input\n\n#### Product Organization Card\n- Vendor: Flux input, placeholder \"Nike\"\n- Product type: Flux input, placeholder \"T-Shirts\"\n- Tags: Flux input, placeholder \"summer, cotton, sale\" with description \"Separate tags with commas\"\n\n#### Collections Card\n- Flux field with label \"Collections\"\n- For each available collection: a row with checkbox and collection title text\n- Checkbox values bound to `collectionIds`\n\n### Sticky Save Bar\n\nFixed at the bottom of the viewport. Appears when changes are detected.\n- Left-aligned to account for sidebar width on desktop\n- \"Discard\" button (ghost variant) - links back to products index\n- \"Save\" button (primary variant) - calls `save()`, shows \"Saving...\" text during loading, disabled while loading\n\n### Delete Confirmation Modal (Edit Only)\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-delete-product` |\n| Max width | md |\n| Heading | \"Delete this product?\" |\n| Body text | \"This product will be archived. Products with existing orders cannot be permanently removed.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `deleteProduct` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Section titles | `heading` (size md) |\n| All form labels | `field`, label via Flux |\n| Text inputs | `input` |\n| Textarea | `textarea` |\n| Status/type selects | `select` |\n| Checkboxes | `checkbox` |\n| Validation errors | `field` error display |\n| Tags description | `field` description |\n| Save/discard buttons | `button` |\n| Delete confirmation | `modal` |\n| Section dividers | `separator` |\n| Variant icons | `icon` |\n| Status text | `text` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading (save) | Save button disabled, text changes to \"Saving...\" |\n| Loading (upload) | Progress bar shown in media section |\n| Create mode | Empty form, no delete button, heading says \"Add product\" |\n| Edit mode | Pre-filled form, delete button visible, heading shows product title |\n| Validation error | Inline error messages below each invalid field |\n\n---\n\n## 5. Collections List and Edit\n\n### 5.1 Collections List\n\n**Route:** `GET /admin/collections`\n\n**Livewire component:** `App\\Livewire\\Admin\\Collections\\Index`\n\n**Blade view:** `resources/views/livewire/admin/collections/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query |\n| `statusFilter` | `string` | `all`, `active`, `archived` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `deleteCollection(int $id)` | Deletes a collection after confirmation |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `collections` | Paginated, filtered, searched. Includes `withCount('products')`. |\n\n#### Layout\n\nSame pattern as Products list:\n- Heading \"Collections\" with \"Add collection\" primary button\n- Search input\n- Status filter dropdown\n\n#### Table Columns\n\n| Column | Content |\n|--------|---------|\n| Title | Link to edit page |\n| Products count | Number of products in collection |\n| Status | Flux badge |\n| Updated date | Relative timestamp |\n\n**Empty state:** \"Create your first collection\" with CTA button.\n\n### 5.2 Collection Edit/Create\n\n**Route:** `GET /admin/collections/create` and `GET /admin/collections/{collection}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Collections\\Form`\n\n**Blade view:** `resources/views/livewire/admin/collections/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collection` | `?Collection` | The collection being edited |\n| `title` | `string` | Collection title |\n| `handle` | `string` | URL slug |\n| `descriptionHtml` | `string` | Collection description |\n| `status` | `string` | `active` or `archived` |\n| `productSearch` | `string` | Search input for product assignment |\n| `assignedProductIds` | `array<int>` | Ordered list of product IDs in this collection |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the collection with product assignments |\n| `addProduct(int $productId)` | Adds a product to the collection |\n| `removeProduct(int $productId)` | Removes a product from the collection |\n| `reorderProducts(array $order)` | Updates product positions |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `searchResults` | Products matching `productSearch` that are not already assigned |\n| `assignedProducts` | Ordered collection of assigned Product models |\n\n#### Layout Wireframe\n\n```\n+------------------------------------------+-------------------+\n|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|\n+------------------------------------------+-------------------+\n|  Title: [Summer Collection         ]     | Status: [Active v] |\n|  Handle: [summer-collection        ]     |                   |\n|  Description:                            |                   |\n|  [textarea...                      ]     |                   |\n|                                          |                   |\n|  PRODUCTS                                |                   |\n|  Search: [Search products...       ]     |                   |\n|  +----------------------------------+   |                   |\n|  | Blue Shirt           [Add]       |   |                   |\n|  | Red Hat              [Add]       |   |                   |\n|  +----------------------------------+   |                   |\n|                                          |                   |\n|  Assigned products:                      |                   |\n|  [drag] [img] Sneakers         [remove]  |                   |\n|  [drag] [img] Blue Shirt       [remove]  |                   |\n+------------------------------------------+-------------------+\n\n+==================================================================+\n| STICKY SAVE BAR                          [Discard]  [Save]       |\n+==================================================================+\n```\n\n**Left column:**\n- Title input\n- Handle input\n- Description textarea\n- Products assignment section:\n  - Search input with live debounce (300ms)\n  - Search results dropdown (conditionally shown): list of matching products with \"Add\" button\n  - Assigned products list: each row shows product title, thumbnail, and a remove button. Rows are sortable via drag-to-reorder.\n\n**Right column:**\n- Status: Flux select with Active / Archived\n- Sticky save bar (same pattern as product form)\n\n---\n\n## 6. Inventory Management\n\n**Route:** `GET /admin/inventory`\n\n**Livewire component:** `App\\Livewire\\Admin\\Inventory\\Index`\n\n**Blade view:** `resources/views/livewire/admin/inventory/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by product title or SKU |\n| `stockFilter` | `string` | `all`, `in_stock`, `low_stock`, `out_of_stock` |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `inventoryItems` | Paginated inventory items with variant and product eager loaded, filtered by search and stock level |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Inventory                                                         |\n+------------------------------------------------------------------+\n| [search] Search by product or SKU   [Stock: All v]               |\n+------------------------------------------------------------------+\n| Product | Variant | SKU     | On Hand | Reserved | Policy | Act  |\n|---------|---------|---------|---------|----------|--------|------|\n| Shirt   | S/Blue  | SH-001  | [45]    | 3        | deny   | ...  |\n| Shirt   | M/Blue  | SH-002  | [0]     | 0        | deny   | ...  |\n| Hat     | Red     | HT-001  | [12]    | 1        | cont   | ...  |\n+------------------------------------------------------------------+\n```\n\n### Layout\n\n- Heading \"Inventory\"\n- Search input and stock filter dropdown\n- Table columns: Product, Variant, SKU, On Hand (inline-editable number input), Reserved, Policy (shown as Flux badge: \"deny\" or \"continue\"), Actions\n\n---\n\n## 7. Orders List\n\n**Route:** `GET /admin/orders`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Index`\n\n**Blade view:** `resources/views/livewire/admin/orders/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by order number or customer email |\n| `statusFilter` | `string` | `all`, `pending`, `paid`, `fulfilled`, `cancelled`, `refunded` |\n| `sortField` | `string` | Sort column (default: `placed_at`) |\n| `sortDirection` | `string` | `asc` or `desc` (default: `desc`) |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `sortBy(string $field)` | Updates sort |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `orders` | Paginated query with search, status filter, sorting. Eager loads `customer`. |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Orders                                                            |\n+------------------------------------------------------------------+\n| [search] Search by order # or email...                           |\n+------------------------------------------------------------------+\n| [All] [Pending] [Paid] [Fulfilled] [Cancelled] [Refunded]       |\n+------------------------------------------------------------------+\n| Order #  | Date           | Customer  | Payment   | Fulfill | Total  |\n|----------|----------------|-----------|-----------|---------|--------|\n| #1001    | Jan 15, 2:30 PM| John Doe  | Paid      | Unful.  | $131.36|\n| #1000    | Jan 14, 9:15 AM| Guest     | Pending   | Unful.  | $45.00 |\n+------------------------------------------------------------------+\n| < 1 2 3 >                                                        |\n+------------------------------------------------------------------+\n```\n\n### Filter Tabs\n\nA horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.\n\n### Table Column Definitions\n\n| Column | Content |\n|--------|---------|\n| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n| Customer | Customer name or \"Guest\" |\n| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n| Total | Formatted currency amount |\n\nPagination below the table.\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Search field | `input` |\n| Status badges | `badge` |\n| Cell text | `text` |\n\n---\n\n## 8. Order Detail\n\n**Route:** `GET /admin/orders/{order}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n\n**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `createFulfillment()` | Creates a fulfillment record for selected lines |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |\n\n### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n| Jan 15, 2024 2:30 PM                        | John Doe           |\n|                                              | john@example.com   |\n| [Create fulfillment]  [Refund]               | > View customer    |\n|                                              |--------------------|\n| TIMELINE                                     | SHIPPING ADDRESS   |\n| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n| |                                            | Springfield, IL    |\n| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n| |                                            | United States      |\n| o  (more events...)                          |--------------------|\n|                                              | BILLING ADDRESS    |\n| ORDER LINES                                  | 123 Main St        |\n| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n|                                              |                    |\n|                    Subtotal     $140.00       |                    |\n|                    Discount     -$12.00       |                    |\n|                    Shipping      $5.00        |                    |\n|                    Tax          $18.36        |                    |\n|                    Total       $151.36        |                    |\n|                                              |                    |\n| PAYMENT DETAILS                              |                    |\n| Provider: Stripe  [Paid]                     |                    |\n| Amount: $151.36                              |                    |\n| Payment ID: pi_xxx...                        |                    |\n+----------------------------------------------+--------------------+\n```\n\n### Left Column Sections\n\n#### Order Heading\n- Order number as a large heading\n- Financial status badge (color mapped: paid=green, default=zinc)\n- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n- Placed-at date as muted text\n\n#### Action Buttons\nShown below the heading:\n- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n- \"Refund\" button (ghost variant, shown when order is paid)\n\n#### Timeline\nA vertical timeline with left border line and dot markers:\n- Each event: title text (bold) and timestamp text (small, muted)\n- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n\n#### Order Lines Table\n\n| Column | Content |\n|--------|---------|\n| Image | 40x40 product thumbnail |\n| Product | Title and SKU |\n| Quantity | Unit count |\n| Unit Price | Formatted amount |\n| Total | Line total formatted |\n\n#### Order Summary\nA right-aligned summary block below the lines table:\n- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n- Each row is a flex container with label left and amount right\n\n#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)\n\n### Right Column Sections\n\n#### Customer Card\n- Heading \"Customer\" (size md)\n- Separator\n- Customer name (or \"Guest\")\n- Customer email (muted text)\n- \"View customer\" link (shown only if customer exists, links to customer detail page)\n\n#### Shipping Address Card\n- Heading \"Shipping address\" (size md)\n- Separator\n- Full address rendered from shipping_address_json: line1, city/state/zip, country\n\n#### Billing Address Card\nSame pattern as shipping address, using billing_address_json.\n\n### Fulfillment Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-fulfillment` |\n| Max width | lg |\n| Heading | \"Create fulfillment\" |\n\n**Content:**\n- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n- Separator\n- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n- Tracking number input field\n- Tracking URL input field (type: url)\n- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n\n### Refund Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-refund` |\n| Max width | lg |\n| Heading | \"Refund order\" |\n\n**Content:**\n- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n- Separator\n- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n- Cancel button (ghost) and \"Create refund\" button (danger)\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Order number heading | `heading` |\n| Status badges | `badge` |\n| Action buttons | `button` |\n| Section headings | `heading` (size md) |\n| Section dividers | `separator` |\n| Text content | `text` |\n| Both modals | `modal` |\n| Modal form fields | `field`, `input`, `textarea` |\n| Line checkboxes | `checkbox` |\n| Modal buttons | `button` |\n| Icons | `icon` |\n| Callout messages | `callout` |\n\n---\n\n## 9. Customers List and Detail\n\n### 9.1 Customers List\n\n**Route:** `GET /admin/customers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by name or email |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `customers` | Paginated, searched customers with `withCount('orders')` and `withSum('orders', 'total_amount')` |\n\n#### Layout\n\n- Heading \"Customers\"\n- Search input\n- Table columns: Name, Email, Orders count, Total spent (formatted), Created date\n- Each row links to customer detail\n\n### 9.2 Customer Detail\n\n**Route:** `GET /admin/customers/{customer}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Show`\n\n**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `customer` | `Customer` | The customer model |\n| `editingAddress` | `?CustomerAddress` | Address currently being edited |\n| `addressLabel` | `string` | Label for address form |\n| `addressJson` | `array` | Address fields being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Customer $customer)` | Loads customer with orders and addresses |\n| `openAddressForm(?CustomerAddress $address)` | Opens address modal for create/edit |\n| `saveAddress()` | Saves the address |\n| `deleteAddress(int $addressId)` | Deletes an address |\n| `setDefaultAddress(int $addressId)` | Sets address as default |\n\n#### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| CUSTOMER INFO                                | ADDRESSES          |\n| Name: Jane Smith                             | Home (default)     |\n| Email: jane@example.com                      | 123 Main St        |\n| Created: Jan 1, 2024                         | Springfield, IL    |\n| Marketing: [Opted In]                        | [Edit] [Delete]    |\n|                                              |                    |\n| ORDER HISTORY                                | Office             |\n| Order # | Date      | Status | Total         | 456 Corp Ave       |\n| #1005   | Jan 20    | Paid   | $89.00        | Chicago, IL        |\n| #1001   | Jan 15    | Paid   | $131.36       | [Edit] [Delete]    |\n| < 1 2 >                                     |                    |\n|                                              | [+ Add address]    |\n+----------------------------------------------+--------------------+\n```\n\n**Left column:**\n- Customer info card: Name, email, created date, marketing opt-in status (Flux badge)\n- Order history table: columns Order #, Date, Status, Total. Each order links to order detail. Paginated.\n\n**Right column:**\n- Addresses card: List of addresses, each showing label, full address text, default badge (if `is_default`), Edit and Delete buttons\n- \"Add address\" button opens address modal\n\n#### Address Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `address-form` |\n| Max width | md |\n| Heading | \"Edit address\" or \"Add address\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"Home, Office...\")\n- Address line 1 input\n- Address line 2 input\n- City and State/Province inputs (side by side, 2-column grid)\n- ZIP/Postal code and Country select (side by side, 2-column grid)\n- Cancel button (ghost) and \"Save\" button (primary)\n\n---\n\n## 10. Discounts List and Form\n\n### 10.1 Discounts List\n\n**Route:** `GET /admin/discounts`\n\n**Livewire component:** `App\\Livewire\\Admin\\Discounts\\Index`\n\n**Blade view:** `resources/views/livewire/admin/discounts/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by discount code |\n| `statusFilter` | `string` | `all`, `active`, `expired`, `scheduled` |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `discounts` | Paginated, filtered discounts |\n\n#### Layout\n\n- Heading \"Discounts\" with \"Create discount\" primary button\n- Search input and status filter\n\n#### Table Columns\n\n| Column | Content |\n|--------|---------|\n| Code | Discount code or \"Automatic\" label |\n| Type | Flux badge: \"Code\" or \"Automatic\" |\n| Value | e.g., \"10%\" or \"$5.00\" or \"Free shipping\" |\n| Usage | \"3 / 100\" (used / limit) or \"3 / unlimited\" |\n| Status | Flux badge: active=green, expired=red, scheduled=yellow |\n| Dates | Start and end dates |\n\n### 10.2 Discount Form\n\n**Route:** `GET /admin/discounts/create` and `GET /admin/discounts/{discount}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Discounts\\Form`\n\n**Blade view:** `resources/views/livewire/admin/discounts/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `discount` | `?Discount` | The discount being edited |\n| `type` | `string` | `code` or `automatic` |\n| `code` | `string` | Discount code (if type is code) |\n| `valueType` | `string` | `percent`, `fixed`, `free_shipping` |\n| `valueAmount` | `?int` | Discount value (percentage or fixed amount) |\n| `minimumPurchaseAmount` | `?int` | Minimum cart value for eligibility |\n| `specificProductIds` | `array<int>` | Specific products the discount applies to |\n| `specificCollectionIds` | `array<int>` | Specific collections the discount applies to |\n| `usageLimit` | `?int` | Maximum total uses |\n| `onePerCustomer` | `bool` | Limit to one use per customer |\n| `startsAt` | `string` | Start datetime |\n| `endsAt` | `?string` | End datetime (optional) |\n| `isActive` | `bool` | Whether the discount is active |\n| `productSearch` | `string` | Search for products to add |\n| `collectionSearch` | `string` | Search for collections to add |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the discount |\n| `generateCode()` | Auto-generates a random discount code |\n| `addProduct(int $productId)` | Adds product to specific products list |\n| `removeProduct(int $productId)` | Removes product |\n| `addCollection(int $collectionId)` | Adds collection to specific collections |\n| `removeCollection(int $collectionId)` | Removes collection |\n\n#### Layout Wireframe (Single Column)\n\n```\n+==================================================================+\n| TYPE CARD                                                         |\n|  ( ) Discount code - Customers enter a code at checkout          |\n|  (o) Automatic discount - Applied automatically at checkout      |\n+==================================================================+\n| CODE CARD (shown when type = \"code\")                             |\n|  Discount code: [SUMMER20             ]  [Generate]              |\n+==================================================================+\n| VALUE CARD                                                        |\n|  ( ) Percentage                                                   |\n|  (o) Fixed amount                                                 |\n|  ( ) Free shipping                                                |\n|                                                                   |\n|  Amount: [5.00                        ] (hidden for free shipping)|\n+==================================================================+\n| CONDITIONS CARD                                                   |\n|  Minimum purchase amount: [0.00       ]                          |\n|  (Leave empty for no minimum)                                     |\n|                                                                   |\n|  Specific products:                                               |\n|  [Search products...          ]                                   |\n|  +---results dropdown---+                                         |\n|  Selected: Blue Shirt [x]  Red Hat [x]                           |\n|                                                                   |\n|  Specific collections: (same pattern as products)                 |\n+==================================================================+\n| USAGE LIMITS CARD                                                 |\n|  Total usage limit: [100              ] (or leave blank)         |\n|  [x] Limit to one use per customer                               |\n+==================================================================+\n| ACTIVE DATES CARD                                                 |\n|  Start date: [2024-01-15 00:00       ]                           |\n|  End date:   [2024-02-15 00:00       ] (or leave blank)          |\n+==================================================================+\n| STATUS                                                            |\n|  [switch: ON]  Active                                             |\n+==================================================================+\n| STICKY SAVE BAR                          [Discard]  [Save]       |\n+==================================================================+\n```\n\n#### Section Details\n\n**Type section:** Two radio buttons - \"Discount code\" (description: \"Customers enter a code at checkout\") and \"Automatic discount\" (description: \"Applied automatically at checkout\"). Selecting type is live-bound.\n\n**Code input:** Shown only when type = \"code\". Text input with placeholder \"SUMMER20\" and a \"Generate\" ghost button that auto-generates a random code.\n\n**Value section:** Three radio buttons for value type: Percentage, Fixed amount, Free shipping. When not free shipping, a number input appears for the amount (label changes based on type: \"Percentage\" or \"Amount\").\n\n**Conditions section:**\n- Minimum purchase amount: number input with description \"Leave empty for no minimum\"\n- Specific products: search input with live debounce. Results appear in a scrollable dropdown. Selected products shown as a list with remove (X) buttons.\n- Specific collections: same pattern as products.\n\n**Usage limits section:**\n- Total usage limit number input (placeholder: \"Unlimited\")\n- \"Limit to one use per customer\" checkbox\n\n**Active dates section:**\n- Start date: datetime-local input\n- End date: datetime-local input with description \"Leave empty for no end date\"\n\n**Status:** Toggle switch with text label \"Active\" or \"Disabled\".\n\nSticky save bar at the bottom (same pattern as product form).\n\n---\n\n## 11. Settings Pages\n\n### 11.1 General Settings\n\n**Route:** `GET /admin/settings`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\General`\n\n**Blade view:** `resources/views/livewire/admin/settings/general.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `storeName` | `string` | Store display name |\n| `storeHandle` | `string` | Store handle (read-only or with warning) |\n| `defaultCurrency` | `string` | Currency code (e.g., \"EUR\") |\n| `defaultLocale` | `string` | Locale code (e.g., \"en\") |\n| `timezone` | `string` | Timezone string |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves store settings |\n\n#### Layout\n\nTwo-column layout per section: left side has section label and description, right side has form fields.\n\n**Store details section:**\n- Left: Heading \"Store details\" + description \"Basic information about your store.\"\n- Right: Store name input, Store handle input (disabled, with description: \"The store handle cannot be changed after creation.\")\n\nSeparator between sections.\n\n**Defaults section:**\n- Left: Heading \"Defaults\" + description \"Currency, language, and timezone settings.\"\n- Right:\n  - Default currency: Select (EUR/USD/GBP and more)\n  - Default locale: Select (English/German/French and more)\n  - Timezone: Select (populated from PHP timezone list)\n\nSave button at the bottom (primary variant, right-aligned).\n\n### 11.2 Domains Settings\n\nDomains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Domains`\n\n**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `domains` | `Collection` | Store domains |\n| `newHostname` | `string` | Hostname for new domain |\n| `newType` | `string` | Type for new domain: `storefront`, `admin`, `api` |\n\n| Action | Behavior |\n|--------|----------|\n| `addDomain()` | Creates a new domain record |\n| `removeDomain(int $domainId)` | Removes a domain |\n| `setPrimary(int $domainId)` | Sets domain as primary |\n\n#### Layout\n\n- Heading \"Domains\"\n- Table of existing domains:\n\n| Column | Content |\n|--------|---------|\n| Hostname | Domain hostname |\n| Type | Flux badge: storefront/admin/api |\n| Primary | Flux badge (green) \"Primary\" if `is_primary` |\n| TLS | Flux badge showing TLS mode |\n| Actions | \"Set Primary\" button, \"Delete\" button |\n\n- \"Add domain\" button opens a modal\n\n#### Add Domain Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `add-domain` |\n| Max width | md |\n| Heading | \"Add domain\" |\n\n**Form fields:**\n- Hostname input (placeholder: \"shop.example.com\")\n- Type select: Storefront, Admin, API\n- Cancel button (ghost) and \"Add domain\" button (primary)\n\n### 11.3 Shipping Settings\n\n**Route:** `GET /admin/settings/shipping`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Shipping`\n\n**Blade view:** `resources/views/livewire/admin/settings/shipping.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `zones` | `Collection` | Shipping zones with rates |\n| `editingZone` | `?ShippingZone` | Zone being edited |\n| `zoneName` | `string` | Name for zone form |\n| `zoneCountries` | `array` | Selected countries for zone |\n| `editingRate` | `?ShippingRate` | Rate being edited |\n| `rateName` | `string` | Rate name |\n| `rateType` | `string` | `flat`, `weight`, `price`, `carrier` |\n| `rateConfig` | `array` | Rate configuration (depends on type) |\n| `rateActive` | `bool` | Whether rate is active |\n| `testAddress` | `array` | Address fields for test tool |\n| `testResult` | `?array` | Result of test address lookup |\n\n| Action | Behavior |\n|--------|----------|\n| `openZoneModal(?ShippingZone $zone)` | Opens zone create/edit modal |\n| `saveZone()` | Saves shipping zone |\n| `deleteZone(int $zoneId)` | Deletes a shipping zone |\n| `openRateModal(int $zoneId, ?ShippingRate $rate)` | Opens rate create/edit modal |\n| `saveRate()` | Saves shipping rate |\n| `deleteRate(int $rateId)` | Deletes a rate |\n| `testShippingAddress()` | Tests which zone/rate matches a given address |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| Shipping                                         [+ Add zone]    |\n+==================================================================+\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | ZONE: Domestic                            [Edit] [Delete]    | |\n| | Countries: US, CA                                             | |\n| |                                                               | |\n| | Name      | Type    | Config           | Active | Actions    | |\n| |-----------|---------|------------------|--------|------------|  |\n| | Standard  | flat    | $5.00            | [on]   | [Ed] [Del]| |\n| | Express   | flat    | $15.00           | [on]   | [Ed] [Del]| |\n| | [+ Add rate]                                                  | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | ZONE: Europe                              [Edit] [Delete]    | |\n| | Countries: DE, FR, GB, ...                                    | |\n| | (rates table...)                                              | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n| TEST SHIPPING ADDRESS                                             |\n| Country: [v]  State: [    ]  City: [    ]  ZIP: [    ]           |\n| [Test]                                                            |\n| Result: Matched zone: Domestic                                    |\n|         Standard - $5.00 / Express - $15.00                      |\n+==================================================================+\n```\n\nEach zone is displayed as a card with:\n- Zone name heading and Edit/Delete buttons in the header\n- Countries list as muted text\n- Rates table inside the zone card with columns: Name, Type (as badge), Config summary, Active (toggle switch), Actions (Edit/Delete buttons)\n- \"Add rate\" button at the bottom of each zone card\n\n\"Add zone\" button at the top of the page.\n\n#### Zone Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `zone-form` |\n| Max width | md |\n| Heading | \"Edit shipping zone\" or \"Add shipping zone\" (dynamic) |\n\n**Form fields:**\n- Zone name input (placeholder: \"Domestic, Europe, International...\")\n- Countries: scrollable checklist of all available countries with checkboxes\n- Cancel button (ghost) and \"Save zone\" button (primary)\n\n#### Rate Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `rate-form` |\n| Max width | md |\n| Heading | \"Edit shipping rate\" or \"Add shipping rate\" (dynamic) |\n\n**Form fields:**\n- Rate name input (placeholder: \"Standard, Express...\")\n- Rate type select: Flat rate, Weight-based, Price-based, Carrier-calculated\n- Dynamic config fields based on rate type:\n  - **Flat:** Price input\n  - **Weight-based:** Min weight (g) input, Max weight (g) input, Price input\n  - **Price-based:** Min order amount input, Max order amount input, Price input\n  - **Carrier:** Info callout: \"Carrier-calculated rates require a carrier integration to be configured.\"\n- Active toggle switch with \"Active\" text label\n- Cancel button (ghost) and \"Save rate\" button (primary)\n\n#### Test Shipping Address Tool\n\nA card section at the bottom of the page:\n- Heading \"Test shipping address\"\n- Description: \"Enter an address to see which shipping zone and rates match.\"\n- 2-column grid of fields: Country (select), State/Region (input), City (input), ZIP/Postal code (input)\n- \"Test\" button\n- Result area (shown after test):\n  - If matched: \"Matched zone: [zone name]\" and list of available rates with prices\n  - If no match: Warning callout \"No shipping zone matches this address.\"\n\n### 11.4 Tax Settings\n\n**Route:** `GET /admin/settings/taxes`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Taxes`\n\n**Blade view:** `resources/views/livewire/admin/settings/taxes.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `mode` | `string` | `manual` or `provider` |\n| `pricesIncludeTax` | `bool` | Whether prices include tax |\n| `provider` | `string` | Provider name (e.g., `stripe_tax`) |\n| `providerApiKey` | `string` | API key for tax provider |\n| `manualRates` | `array` | Array of `{zone_name, rate_percentage}` |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Saves tax settings |\n| `addManualRate()` | Adds a new empty rate row |\n| `removeManualRate(int $index)` | Removes a manual rate |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| TAXES                                                             |\n+==================================================================+\n| MODE SELECTION                                                    |\n|  ( ) Manual tax rates - Define tax rates per zone manually       |\n|  (o) Tax provider - Use an automated tax calculation service     |\n+==================================================================+\n| MANUAL RATES TABLE (shown when mode = \"manual\")                  |\n| Zone name       | Rate (%)    |                                   |\n|-----------------|-------------|                                   |\n| [EU           ] | [19.00    ] | [trash]                          |\n| [US-CA        ] | [7.25     ] | [trash]                          |\n| [+ Add rate]                                                      |\n+==================================================================+\n| PROVIDER CONFIG (shown when mode = \"provider\")                   |\n|  Provider: [Stripe Tax v]                                        |\n|  API key:  [*************]                                       |\n+==================================================================+\n| [separator]                                                       |\n| TAX-INCLUSIVE TOGGLE                                              |\n|  [switch: ON]  Prices include tax                                |\n|  Description: When enabled, the listed price includes tax.       |\n|  Tax is calculated backwards from the price.                      |\n+==================================================================+\n| [Save]                                                            |\n+==================================================================+\n```\n\n**Mode selection:** Two radio buttons - \"Manual tax rates\" (description: \"Define tax rates per zone manually\") and \"Tax provider\" (description: \"Use an automated tax calculation service\"). Live-bound.\n\n**Manual rates table:** Shown when mode = \"manual\". Inline-editable table with columns: Zone name (text input), Rate % (number input with step 0.01). Each row has a trash button. \"Add rate\" ghost button with plus icon.\n\n**Provider config:** Shown when mode = \"provider\". Provider select (Stripe Tax / None) and API key password input.\n\n**Tax-inclusive toggle:** Toggle switch with \"Prices include tax\" label and description text explaining the behavior.\n\nSave button at the bottom.\n\n---\n\n## 12. Themes Management\n\n### 12.1 Themes List\n\n**Route:** `GET /admin/themes`\n\n**Livewire component:** `App\\Livewire\\Admin\\Themes\\Index`\n\n**Blade view:** `resources/views/livewire/admin/themes/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `themes` | `Collection` | All themes for the store |\n\n| Action | Behavior |\n|--------|----------|\n| `publishTheme(int $themeId)` | Publishes a theme (sets as active) |\n| `duplicateTheme(int $themeId)` | Creates a copy of the theme |\n| `deleteTheme(int $themeId)` | Deletes a theme |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| Themes                                                            |\n+==================================================================+\n|                                                                   |\n| +------------------+ +------------------+ +------------------+   |\n| | [preview image]  | | [preview image]  | | [preview image]  |   |\n| |                  | |                  | |                  |   |\n| | My Theme   v1.0  | | Dark Theme v1.2  | | Minimal    v2.0  |   |\n| | [Published]      | | [Draft]          | | [Draft]          |   |\n| |                  | |                  | |                  |   |\n| | [Customize] [...] | | [Customize] [...] | | [Customize] [...] |   |\n| +------------------+ +------------------+ +------------------+   |\n|                                                                   |\n+==================================================================+\n```\n\n**Theme grid:** Responsive 3-column grid (1 on mobile, 2 on md, 3 on lg).\n\nEach theme card contains:\n- **Thumbnail area:** Aspect-video container with theme preview image or placeholder\n- **Info section:**\n  - Theme name (heading, size md) and version text\n  - Status badge (published=green, draft=zinc)\n- **Actions:**\n  - \"Customize\" primary button (links to theme editor)\n  - More actions dropdown (triggered by ellipsis icon button):\n    - \"Preview\" menu item\n    - \"Publish\" menu item (hidden if already published)\n    - \"Duplicate\" menu item\n    - Separator\n    - \"Delete\" menu item (styled red/danger)\n\nThe published theme card has a blue ring border highlight.\n\n### 12.2 Theme Editor\n\n**Route:** `GET /admin/themes/{theme}/editor`\n\n**Livewire component:** `App\\Livewire\\Admin\\Themes\\Editor`\n\n**Blade view:** `resources/views/livewire/admin/themes/editor.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `theme` | `Theme` | The theme being edited |\n| `sections` | `array` | List of theme sections from settings schema |\n| `selectedSection` | `?string` | Currently selected section key |\n| `sectionSettings` | `array` | Settings values for the selected section |\n| `previewUrl` | `string` | URL for the live preview iframe |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Theme $theme)` | Loads theme and its sections |\n| `selectSection(string $sectionKey)` | Changes the selected section and loads its settings |\n| `updateSetting(string $key, mixed $value)` | Updates a setting value |\n| `save()` | Saves all section settings to theme_settings |\n| `publish()` | Publishes the theme |\n| `refreshPreview()` | Reloads the preview iframe |\n\n#### Layout Wireframe (Full Width, Three-Panel)\n\n```\n+==================================================================+\n| [<- Back to themes]                     [Save]  [Save & publish] |\n+==================================================================+\n| SECTIONS   |  LIVE PREVIEW (iframe)       |  SETTINGS PANEL      |\n| (264px)    |  (flexible width)            |  (320px)             |\n|            |                              |                      |\n| Header     |  +------------------------+ |  Header Settings     |\n| Hero       |  |                        | |  [separator]         |\n| Features*  |  |  Live storefront       | |                      |\n| Products   |  |  preview rendered      | |  Logo text:          |\n| Footer     |  |  in an iframe          | |  [My Store        ]  |\n|            |  |                        | |                      |\n|            |  |                        | |  Background color:   |\n|            |  |                        | |  [color picker]      |\n|            |  |                        | |                      |\n|            |  |                        | |  Show search:        |\n|            |  |                        | |  [x] checkbox        |\n|            |  |                        | |                      |\n|            |  +------------------------+ |  Layout:             |\n|            |                              |  [Centered       v]  |\n+==================================================================+\n  * = currently selected section (highlighted)\n```\n\n**Full-width layout** (no standard admin shell padding). Three-panel layout:\n\n**Top toolbar:** A horizontal bar with:\n- \"Back to themes\" button (ghost, with left arrow icon) linking to themes index\n- \"Save\" ghost button and \"Save and publish\" primary button\n\n**Left panel (264px):** Section list\n- Heading \"Sections\"\n- Vertical list of section buttons\n- Active section gets highlighted background and bold text\n- Clicking a section calls `selectSection()`\n\n**Center panel (flexible):** Live preview\n- Background color behind the iframe\n- Full-height iframe loading the storefront preview URL\n- Iframe has rounded border and shadow\n\n**Right panel (320px):** Settings form\n- Shows settings for the selected section\n- Heading with section label\n- Separator\n- Dynamic form fields based on section field definitions:\n\n| Field Type | UI Element |\n|-----------|------------|\n| `text` | Flux input (live-debounced at 500ms) |\n| `textarea` | Flux textarea (live-debounced at 500ms, 3 rows) |\n| `color` | Native color picker input |\n| `select` | Flux select with options from field definition |\n| `checkbox` | Flux checkbox |\n\n- When no section is selected: centered text \"Select a section to edit its settings.\"\n\n---\n\n## 13. Pages Management\n\n### 13.1 Pages List\n\n**Route:** `GET /admin/pages`\n\n**Livewire component:** `App\\Livewire\\Admin\\Pages\\Index`\n\n**Blade view:** `resources/views/livewire/admin/pages/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by page title |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `pages` | Paginated pages for the current store |\n\n#### Layout\n\n- Heading \"Pages\" with \"Add page\" primary button\n- Search input\n- Table columns: Title (link to edit), Handle, Status (Flux badge), Updated date\n- Pagination\n\n### 13.2 Page Form\n\n**Route:** `GET /admin/pages/create` and `GET /admin/pages/{page}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Pages\\Form`\n\n**Blade view:** `resources/views/livewire/admin/pages/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `page` | `?Page` | Page being edited |\n| `title` | `string` | Page title |\n| `handle` | `string` | URL handle |\n| `bodyHtml` | `string` | Page body content (HTML) |\n| `status` | `string` | `draft` or `active` |\n| `publishedAt` | `?string` | Publish date |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the page |\n| `deletePage()` | Deletes the page |\n\n#### Layout (Two-Column)\n\n**Left column (2/3):**\n- Title: Flux input\n- Handle: Flux input\n- Body: Flux textarea (16 rows)\n\n**Right column (1/3):**\n- Status: Flux select with Draft/Active\n- Published at: Datetime-local input\n\nSticky save bar (same pattern as other forms).\n\n---\n\n## 14. Navigation Management\n\n**Route:** `GET /admin/navigation`\n\n**Livewire component:** `App\\Livewire\\Admin\\Navigation\\Index`\n\n**Blade view:** `resources/views/livewire/admin/navigation/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `menus` | `Collection` | Navigation menus (Main Menu, Footer Menu) |\n| `editingMenu` | `?NavigationMenu` | Menu being edited |\n| `menuItems` | `array` | Nested array of menu items for the editing menu |\n| `editingItem` | `?array` | Item currently being edited in the modal |\n| `editingItemIndex` | `?int` | Index of item being edited |\n| `itemLabel` | `string` | Item label |\n| `itemType` | `string` | `link`, `page`, `collection`, `product` |\n| `itemUrl` | `string` | URL if type is link |\n| `itemResourceId` | `?int` | Resource ID if type is page/collection/product |\n\n| Action | Behavior |\n|--------|----------|\n| `selectMenu(int $menuId)` | Loads a menu for editing |\n| `addItem()` | Opens the item form modal in create mode |\n| `editItem(int $index)` | Opens the item form modal in edit mode |\n| `saveItem()` | Saves the item (add or update) |\n| `removeItem(int $index)` | Removes an item |\n| `reorderItems(array $order)` | Updates item positions (supports nesting) |\n| `saveMenu()` | Persists all menu items |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Navigation                                                        |\n+==================================================================+\n|                                                                   |\n| MENUS                                                             |\n| +----------------------------+  +----------------------------+   |\n| | Main Menu        [Edit]    |  | Footer Menu       [Edit]   |   |\n| +----------------------------+  +----------------------------+   |\n|                                                                   |\n| MENU EDITOR (shown when a menu is selected)                      |\n| +--------------------------------------------------------------+ |\n| | Main Menu                                    [+ Add item]    | |\n| |                                                               | |\n| | [drag] Home               link: /            [edit] [trash]  | |\n| | [drag] Products           collection: All    [edit] [trash]  | |\n| | [drag] About Us           page: About        [edit] [trash]  | |\n| | [drag] Contact            link: /contact     [edit] [trash]  | |\n| |                                                               | |\n| |                                            [Save menu]       | |\n| +--------------------------------------------------------------+ |\n+==================================================================+\n```\n\n**Menu list:** Each menu displayed as a card with title and \"Edit\" button.\n\n**Menu editor (shown when a menu is selected):**\n- Menu title as heading\n- \"Add item\" button (ghost, plus icon) in the header\n- Sortable list of menu items, each row containing:\n  - Drag handle (bars icon)\n  - Item label (bold text)\n  - Item type and target (small, muted text: e.g., \"link: /about\" or \"collection: Summer Sale\")\n  - Edit button (pencil icon) and Delete button (trash icon)\n- \"Save menu\" primary button at the bottom\n\n#### Menu Item Form Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `item-form` |\n| Max width | md |\n| Heading | \"Edit menu item\" or \"Add menu item\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"About Us\")\n- Type select: Custom link, Page, Collection, Product (live-bound)\n- Conditional fields based on type:\n  - **Custom link:** URL input (placeholder: \"https://...\")\n  - **Page/Collection/Product:** Resource select dropdown populated with available resources of that type\n- Cancel button (ghost) and \"Save item\" button (primary)\n\n---\n\n## 15. Apps Page\n\n**Route:** `GET /admin/apps`\n\n**Livewire component:** `App\\Livewire\\Admin\\Apps\\Index`\n\n**Blade view:** `resources/views/livewire/admin/apps/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `installedApps` | `Collection` | Apps installed for this store |\n\n| Action | Behavior |\n|--------|----------|\n| `uninstallApp(int $appId)` | Uninstalls an app (with confirmation) |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Apps                                                              |\n+==================================================================+\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | [icon]  My Integration App                         [Active]  | |\n| |         Installed 3 months ago                               | |\n| +--------------------------------------------------------------+ |\n| +--------------------------------------------------------------+ |\n| | [icon]  Analytics Plugin                          [Inactive] | |\n| |         Installed 1 week ago                                 | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n+==================================================================+\n```\n\nList of installed app cards, each containing:\n- App icon placeholder (squares icon in a rounded container)\n- App name (heading, size md)\n- Install date (relative timestamp, muted text)\n- Status badge (active=green, inactive=zinc)\n\nEach app links to a detail page showing:\n- Scopes granted (list of permission strings)\n- Webhook subscriptions table: event type, URL, status\n- Usage stats (API calls count, last call timestamp)\n\n**Empty state:** \"No apps installed\" with descriptive text.\n\n---\n\n## 16. Developers Page\n\n**Route:** `GET /admin/developers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Developers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/developers/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `tokens` | `Collection` | API tokens for the store |\n| `webhooks` | `Collection` | Webhook subscriptions |\n| `newTokenName` | `string` | Name for new token |\n| `generatedToken` | `?string` | Newly generated token (shown once) |\n| `webhookEventType` | `string` | Event type for webhook form |\n| `webhookUrl` | `string` | URL for webhook form |\n| `editingWebhook` | `?WebhookSubscription` | Webhook being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `generateToken()` | Creates a new API token |\n| `revokeToken(int $tokenId)` | Revokes a token |\n| `openWebhookModal(?WebhookSubscription $webhook)` | Opens webhook create/edit modal |\n| `saveWebhook()` | Saves webhook subscription |\n| `deleteWebhook(int $webhookId)` | Deletes a webhook |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Developers                                                        |\n+==================================================================+\n|                                                                   |\n| API TOKENS                                                        |\n| Manage personal access tokens for the Admin API.                 |\n|                                                                   |\n| Name           | Last used      | Created       | Actions        |\n|----------------|----------------|---------------|----------------|\n| My integration | 2 hours ago    | Jan 1, 2024   | [Revoke]       |\n| CI Pipeline    | Never          | Feb 10, 2024  | [Revoke]       |\n|                                                                   |\n| [Generate new token]                                              |\n|                                                                   |\n| (after generating, shown once:)                                   |\n| [!] Copy this token now. It will not be shown again.             |\n| [sk_live_abc123xyz789...]                                        |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| WEBHOOKS                                                          |\n| Manage webhook subscriptions for real-time event notifications.  |\n|                                                                   |\n| Event type           | URL                    | Status  | Act    |\n|----------------------|------------------------|---------|--------|\n| order.created        | https://ex.com/hook    | Active  | [E][D] |\n| product.updated      | https://ex.com/prod    | Failing | [E][D] |\n|                                                                   |\n| [+ Add webhook]                                                   |\n+==================================================================+\n```\n\nTwo sections separated by a Flux separator.\n\n**API Tokens section:**\n- Heading \"API tokens\" (size lg)\n- Description text\n- Token table with columns: Name, Last used (relative or \"Never\"), Created (date), Actions (Revoke button)\n- \"Generate new token\" button opens modal\n- When a token is generated: a warning callout is displayed with the token value and a note that it will only be shown once\n\n#### Generate Token Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `generate-token` |\n| Max width | md |\n| Heading | \"Generate API token\" |\n\n**Form fields:**\n- Token name input (placeholder: \"My integration\")\n- Cancel button (ghost) and \"Generate\" button (primary)\n\n**Webhooks section:**\n- Heading \"Webhooks\" (size lg)\n- Description text\n- Webhook table with columns:\n\n| Column | Content |\n|--------|---------|\n| Event type | e.g., `order.created`, `product.updated` |\n| URL | Endpoint URL |\n| Status | Flux badge: active=green, failing=red |\n| Actions | Edit and Delete buttons |\n\n- \"Add webhook\" button opens modal\n\n#### Webhook Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `webhook-form` |\n| Max width | md |\n| Heading | \"Edit webhook\" or \"Add webhook\" (dynamic) |\n\n**Form fields:**\n- Event type select with options: order.created, order.updated, order.cancelled, product.created, product.updated, product.deleted, customer.created, checkout.completed, fulfillment.created, refund.created\n- Endpoint URL input (type: url, placeholder: \"https://example.com/webhooks\")\n- Cancel button (ghost) and \"Save\" button (primary)\n\n---\n\n## 17. Analytics Page\n\n**Route:** `GET /admin/analytics`\n\n**Livewire component:** `App\\Livewire\\Admin\\Analytics\\Index`\n\n**Blade view:** `resources/views/livewire/admin/analytics/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Date range preset |\n| `customStartDate` | `?string` | Custom start date |\n| `customEndDate` | `?string` | Custom end date |\n| `channelFilter` | `string` | Sales channel filter (`all`, `storefront`, `api`) |\n| `deviceFilter` | `string` | Device filter (`all`, `desktop`, `mobile`, `tablet`) |\n| `totalSales` | `int` | Total sales for the period |\n| `ordersCount` | `int` | Orders count |\n| `averageOrderValue` | `int` | AOV |\n| `conversionRate` | `float` | Conversion rate percentage |\n| `salesChartData` | `array` | Daily sales chart data |\n| `topProducts` | `Collection` | Top products by revenue |\n| `topReferrers` | `array` | Top traffic sources |\n| `isExporting` | `bool` | Whether an export is in progress |\n| `exportUrl` | `?string` | Download URL when export is ready |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Reloads analytics data |\n| `updatedChannelFilter()` | Reloads with channel filter |\n| `updatedDeviceFilter()` | Reloads with device filter |\n| `loadAnalytics()` | Master method to reload all analytics data |\n| `exportCsv()` | Dispatches an async job to generate a CSV export |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Analytics                                                         |\n+==================================================================+\n| [Today v]  [Channel: All v]  [Device: All v]  [Export CSV]       |\n+==================================================================+\n|                                                                   |\n| KPI TILES (same as Dashboard + Conversion Rate)                  |\n| +------------+ +------------+ +------------+ +-----------+       |\n| | Total Sales| | Orders     | | AOV        | | Conv Rate |       |\n| | $45,678    | | 523        | | $87.34     | | 3.2%      |       |\n| +------------+ +------------+ +------------+ +-----------+       |\n|                                                                   |\n| SALES CHART (larger than dashboard version)                      |\n| [line/bar chart - daily revenue/orders]                          |\n|                                                                   |\n| TOP PRODUCTS (10-20 rows)                                         |\n| Rank | Product     | Units Sold | Revenue  | % of Total          |\n| 1    | Blue Shirt  | 145        | $14,500  | 31.7%               |\n| 2    | Red Hat     | 98         | $9,800   | 21.4%               |\n| ...                                                               |\n|                                                                   |\n| TOP REFERRERS                                                     |\n| Source          | Sessions | Orders | Conversion Rate             |\n| Google          | 1,200    | 45     | 3.75%                      |\n| Direct          | 800      | 32     | 4.00%                      |\n| Instagram       | 450      | 12     | 2.67%                      |\n+==================================================================+\n```\n\nSimilar to Dashboard but with additional controls and detail.\n\n**Filter bar:**\n- Date range dropdown (same as Dashboard: Today, Last 7 days, Last 30 days, Custom)\n- Channel filter: Flux select with All, Storefront, API\n- Device filter: Flux select with All, Desktop, Mobile, Tablet\n- Export button: ghost variant with download icon - calls `exportCsv()`\n\nWhen `isExporting` is true, show a loading indicator next to the export button. When `exportUrl` is set, show a download link.\n\n**KPI tiles:** Same as Dashboard (Total Sales, Orders, AOV, Conversion Rate) but with the additional filters applied.\n\n**Sales chart:** Larger chart than Dashboard, line or bar chart showing daily revenue/orders.\n\n**Top Products table:** Extended version with 10-20 rows. Columns: Rank, Product, Units Sold, Revenue, Percentage of Total.\n\n**Top Referrers table:** Columns: Traffic source, Sessions, Orders, Conversion Rate.\n\n---\n\n## 18. Search Settings\n\n**Route:** `GET /admin/search/settings`\n\n**Livewire component:** `App\\Livewire\\Admin\\Search\\Settings`\n\n**Blade view:** `resources/views/livewire/admin/search/settings.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `synonymGroups` | `array` | Array of synonym groups, each a comma-separated string |\n| `stopWords` | `string` | Comma-separated stop words |\n| `lastIndexedAt` | `?string` | Last reindex timestamp |\n| `isReindexing` | `bool` | Whether a reindex is in progress |\n| `reindexProgress` | `?int` | Progress percentage (0-100) |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Saves synonym and stop word settings |\n| `addSynonymGroup()` | Adds a new empty synonym group |\n| `removeSynonymGroup(int $index)` | Removes a synonym group |\n| `triggerReindex()` | Dispatches a reindex job |\n| `pollReindexStatus()` | Checks reindex progress (polled every 2 seconds) |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Search Settings                                                   |\n+==================================================================+\n|                                                                   |\n| SYNONYMS                                                          |\n| Define groups of words that should be treated as equivalent.     |\n|                                                                   |\n| [t-shirt, tee, tshirt              ] [trash]                    |\n| [sneakers, trainers, kicks         ] [trash]                    |\n| [+ Add synonym group]                                             |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| STOP WORDS                                                        |\n| Words that are excluded from search indexing.                    |\n| [textarea: the, a, an, is, are...           ]                   |\n| (Separate words with commas.)                                     |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| SEARCH INDEX                                                      |\n| [Reindex now]  Last indexed: Jan 15, 2024 3:45 PM               |\n|                                                                   |\n| (when reindexing:)                                                |\n| [==============          ] 65% complete                          |\n|                                                                   |\n| [Save]                                                            |\n+==================================================================+\n```\n\n**Synonyms section:**\n- Heading \"Synonyms\" (size lg)\n- Description text\n- For each synonym group: a row with text input (placeholder: \"t-shirt, tee, tshirt\") and trash button\n- \"Add synonym group\" ghost button with plus icon\n\n**Stop words section:**\n- Separator\n- Heading \"Stop words\" (size lg)\n- Description text\n- Textarea (4 rows, placeholder: \"the, a, an, is, are...\")\n- Flux description: \"Separate words with commas.\"\n\n**Reindex section:**\n- Separator\n- Heading \"Search index\" (size lg)\n- \"Reindex now\" button (disabled while reindexing, text changes to \"Reindexing...\")\n- Last indexed timestamp shown beside button (if available)\n- When reindexing: a progress bar (polled every 2 seconds) with percentage text\n\nSave button at the bottom.\n\n---\n\n## 19. Common Patterns and Conventions\n\n### 19.1 List Pages\n\nAll list pages follow a consistent pattern:\n1. Page heading with optional action button (top-right)\n2. Search input with live debounce (300ms)\n3. Filter controls (dropdowns, tabs, or both)\n4. Data table with sortable columns\n5. Pagination at the bottom\n6. Empty state when no records exist (and no filters are active)\n7. Loading states: table body shows reduced opacity during data loading\n\n### 19.2 Form Pages\n\nAll form pages follow a consistent pattern:\n1. Page heading with breadcrumbs\n2. Two-column layout (2/3 + 1/3 on large screens, stacked on mobile)\n3. Left column: primary content fields\n4. Right column: status, metadata, and secondary fields\n5. Sticky save bar at the bottom with \"Discard\" and \"Save\" buttons\n6. Inline validation errors using Flux field component with error display\n7. Loading indicator on save: save button shows loading text and becomes disabled\n8. Success toast dispatched after save\n9. Delete action behind a confirmation Flux modal\n\n### 19.3 Delete Confirmations\n\nEvery destructive action uses a Flux modal for confirmation:\n- Heading describing the action\n- Body text explaining consequences\n- Cancel button (ghost variant) that closes the modal\n- Confirm button (danger variant) that executes the destructive action\n\n### 19.4 Toast Notifications\n\nAll successful actions dispatch a success toast (e.g., \"Changes saved.\"). Error states dispatch an error toast (e.g., \"Something went wrong. Please try again.\").\n\n### 19.5 Breadcrumbs\n\nEvery page includes Flux breadcrumbs:\n- First item: \"Home\" linking to admin dashboard\n- Intermediate items: parent section (e.g., \"Products\" linking to products index)\n- Last item: current page title (no link)\n\n### 19.6 Loading States\n\nAll interactive elements show loading states:\n- **Buttons:** Disabled attribute during loading, text swaps to loading message (e.g., \"Saving...\")\n- **Tables:** Body rows show reduced opacity during data fetching\n- **Charts/tiles:** Spinner overlay targeting the specific loading method\n\n### 19.7 Card Pattern\n\nReusable card wrapper used throughout the admin:\n- White background (dark: zinc-900)\n- Rounded corners (lg)\n- Border (zinc-200, dark: zinc-700)\n- Padding (p-6)\n\n### 19.8 Responsive Design\n\n- All layouts use Tailwind responsive breakpoints: `sm:`, `md:`, `lg:`, `xl:`\n- Sidebar collapses on screens below `lg`\n- Two-column form layouts stack to single column below `lg`\n- Tables become horizontally scrollable on small screens\n- Grid columns reduce on smaller screens (e.g., 4 cols on lg, 2 on sm, 1 on mobile)\n\n### 19.9 Accessibility\n\n- All form inputs have associated Flux label elements\n- Interactive elements are keyboard-navigable\n- Focus states are visible (Tailwind's default focus ring utilities)\n- Flux modal handles focus trapping and Escape key dismissal\n- Color is never the sole indicator of state (badges include text labels)\n- Semantic HTML: headings hierarchy, landmark regions, lists for navigation\n- ARIA attributes where needed (e.g., `aria-expanded` on collapsibles, `aria-current=\"page\"` on active nav)\n\n---\n\n## 20. Flash / Toast Messages\n\nAll flash messages use the toast notification component. Messages are displayed after the relevant action completes.\n\n| Action | Message | Type |\n|--------|---------|------|\n| Product created | \"Product saved\" | success |\n| Product updated | \"Product saved\" | success |\n| Product archived | \"Product archived\" | success |\n| Variant added | \"Variant saved\" | success |\n| Collection saved | \"Collection saved\" | success |\n| Discount created | \"Discount saved\" | success |\n| Discount updated | \"Discount saved\" | success |\n| Order fulfilled | \"Fulfillment created\" | success |\n| Refund issued | \"Refund issued\" | success |\n| Customer created | \"Customer saved\" | success |\n| Customer updated | \"Customer saved\" | success |\n| Settings saved | \"Settings saved\" | success |\n| Theme published | \"Theme published\" | success |\n| Page saved | \"Page saved\" | success |\n| Navigation saved | \"Navigation saved\" | success |\n| Login failed | \"Invalid credentials\" | error |\n| Validation error | Dynamic field-specific message | error |\n\n---\n\n## 21. Livewire Component Summary\n\n| Component Class | Blade View | Route(s) |\n|----------------|-----------|---------|\n| `App\\Livewire\\Admin\\Layout\\Sidebar` | `livewire.admin.layout.sidebar` | Layout (all pages) |\n| `App\\Livewire\\Admin\\Layout\\TopBar` | `livewire.admin.layout.top-bar` | Layout (all pages) |\n| `App\\Livewire\\Admin\\Dashboard` | `livewire.admin.dashboard` | `GET /admin` |\n| `App\\Livewire\\Admin\\Products\\Index` | `livewire.admin.products.index` | `GET /admin/products` |\n| `App\\Livewire\\Admin\\Products\\Form` | `livewire.admin.products.form` | `GET /admin/products/create`, `GET /admin/products/{product}/edit` |\n| `App\\Livewire\\Admin\\Collections\\Index` | `livewire.admin.collections.index` | `GET /admin/collections` |\n| `App\\Livewire\\Admin\\Collections\\Form` | `livewire.admin.collections.form` | `GET /admin/collections/create`, `GET /admin/collections/{collection}/edit` |\n| `App\\Livewire\\Admin\\Inventory\\Index` | `livewire.admin.inventory.index` | `GET /admin/inventory` |\n| `App\\Livewire\\Admin\\Orders\\Index` | `livewire.admin.orders.index` | `GET /admin/orders` |\n| `App\\Livewire\\Admin\\Orders\\Show` | `livewire.admin.orders.show` | `GET /admin/orders/{order}` |\n| `App\\Livewire\\Admin\\Customers\\Index` | `livewire.admin.customers.index` | `GET /admin/customers` |\n| `App\\Livewire\\Admin\\Customers\\Show` | `livewire.admin.customers.show` | `GET /admin/customers/{customer}` |\n| `App\\Livewire\\Admin\\Discounts\\Index` | `livewire.admin.discounts.index` | `GET /admin/discounts` |\n| `App\\Livewire\\Admin\\Discounts\\Form` | `livewire.admin.discounts.form` | `GET /admin/discounts/create`, `GET /admin/discounts/{discount}/edit` |\n| `App\\Livewire\\Admin\\Settings\\General` | `livewire.admin.settings.general` | `GET /admin/settings` |\n| `App\\Livewire\\Admin\\Settings\\Domains` | `livewire.admin.settings.domains` | Tab within `GET /admin/settings` |\n| `App\\Livewire\\Admin\\Settings\\Shipping` | `livewire.admin.settings.shipping` | `GET /admin/settings/shipping` |\n| `App\\Livewire\\Admin\\Settings\\Taxes` | `livewire.admin.settings.taxes` | `GET /admin/settings/taxes` |\n| `App\\Livewire\\Admin\\Themes\\Index` | `livewire.admin.themes.index` | `GET /admin/themes` |\n| `App\\Livewire\\Admin\\Themes\\Editor` | `livewire.admin.themes.editor` | `GET /admin/themes/{theme}/editor` |\n| `App\\Livewire\\Admin\\Pages\\Index` | `livewire.admin.pages.index` | `GET /admin/pages` |\n| `App\\Livewire\\Admin\\Pages\\Form` | `livewire.admin.pages.form` | `GET /admin/pages/create`, `GET /admin/pages/{page}/edit` |\n| `App\\Livewire\\Admin\\Navigation\\Index` | `livewire.admin.navigation.index` | `GET /admin/navigation` |\n| `App\\Livewire\\Admin\\Apps\\Index` | `livewire.admin.apps.index` | `GET /admin/apps` |\n| `App\\Livewire\\Admin\\Developers\\Index` | `livewire.admin.developers.index` | `GET /admin/developers` |\n| `App\\Livewire\\Admin\\Analytics\\Index` | `livewire.admin.analytics.index` | `GET /admin/analytics` |\n| `App\\Livewire\\Admin\\Search\\Settings` | `livewire.admin.search.settings` | `GET /admin/search/settings` |\n","structuredPatch":[{"oldStart":957,"oldLines":7,"newStart":957,"newLines":10,"lines":[" | Action | Behavior |"," |--------|----------|"," | `mount(Order $order)` | Loads order with all relationships |","-| `createFulfillment()` | Creates a fulfillment record for selected lines |","+| `confirmPayment()` | Confirms bank transfer payment received (transitions financial_status to `paid`, commits inventory, dispatches `OrderPaid`) |","+| `createFulfillment()` | Creates a fulfillment record for selected lines (enforces fulfillment guard) |","+| `markAsShipped(int $fulfillmentId)` | Transitions fulfillment to `shipped`, sets `shipped_at` |","+| `markAsDelivered(int $fulfillmentId)` | Transitions fulfillment to `delivered`, sets `delivered_at` |"," | `createRefund()` | Creates a refund for selected lines or custom amount |"," | `openFulfillmentModal()` | Opens the fulfillment modal |"," | `openRefundModal()` | Opens the refund modal |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"dd83137c-455c-49bf-8957-6924aa344e66"}
{"parentUuid":"a6895db4-1bc1-489b-9ce7-a09f9381da76","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01XTY4PDcYnNdDBU6vThksSP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012VGC9dRJ5oQH6XDQ9pYxEN","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","old_string":"| PAYMENT DETAILS                              |                    |\n| Provider: Stripe  [Paid]                     |                    |\n| Amount: $151.36                              |                    |\n| Payment ID: pi_xxx...                        |                    |","new_string":"| PAYMENT DETAILS                              |                    |\n| Method: Credit Card  [Paid]                  |                    |\n| Amount: 151.36 EUR                           |                    |\n| Ref: mock_xxx...                             |                    |\n|                                              |                    |\n| (If bank_transfer + pending:)                |                    |\n| [Confirm Payment] button                     |                    |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":906,"cache_read_input_tokens":117558,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":906},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WWwK3i14trdtMmtTJK","type":"assistant","uuid":"c6bdde74-3d2a-4b23-8842-5c24a86f0e5a","timestamp":"2026-02-11T15:21:58.224Z"}
{"parentUuid":"c6bdde74-3d2a-4b23-8842-5c24a86f0e5a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_012VGC9dRJ5oQH6XDQ9pYxEN","toolUseID":"toolu_012VGC9dRJ5oQH6XDQ9pYxEN","timestamp":"2026-02-11T15:21:58.230Z","uuid":"bf31583c-24f4-44ab-a481-de4d32b52b2d"}
{"parentUuid":"c6bdde74-3d2a-4b23-8842-5c24a86f0e5a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012VGC9dRJ5oQH6XDQ9pYxEN","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md has been updated successfully."}]},"uuid":"54fc585b-c3b7-4b15-8353-59e592bf2c0d","timestamp":"2026-02-11T15:21:58.229Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","oldString":"| PAYMENT DETAILS                              |                    |\n| Provider: Stripe  [Paid]                     |                    |\n| Amount: $151.36                              |                    |\n| Payment ID: pi_xxx...                        |                    |","newString":"| PAYMENT DETAILS                              |                    |\n| Method: Credit Card  [Paid]                  |                    |\n| Amount: 151.36 EUR                           |                    |\n| Ref: mock_xxx...                             |                    |\n|                                              |                    |\n| (If bank_transfer + pending:)                |                    |\n| [Confirm Payment] button                     |                    |","originalFile":"# 03 - Admin Panel UI/UX Specification\n\nThis document defines every view, component, and interaction for the Shopify-clone admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.\n\n**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip\n\nAnything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.\n\n---\n\n## 1. Admin Layout Shell\n\nThe admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.\n\n### 1.1 Route Prefix\n\nAll admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.\n\n### 1.2 Sidebar Navigation\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\Sidebar`\n\n**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |\n| `currentRoute` | `string` | The current route name for active highlighting |\n\n| Action | Behavior |\n|--------|----------|\n| `toggle()` | Toggles the `collapsed` state |\n\n#### Wireframe\n\n```\n+-------------------------------+\n|  [BRAND LOGO]  Platform Name  |\n|-------------------------------|\n|  [separator]                  |\n|                               |\n|  [chart-bar]    Dashboard     |\n|                               |\n|  PRODUCTS                     |\n|  [cube]         Products      |\n|  [rect-stack]   Collections   |\n|  [archive-box]  Inventory     |\n|                               |\n|  [separator]                  |\n|  ORDERS                       |\n|  [shopping-bag] Orders        |\n|                               |\n|  [separator]                  |\n|  CUSTOMERS                    |\n|  [users]        Customers     |\n|                               |\n|  [separator]                  |\n|  DISCOUNTS                    |\n|  [tag]          Discounts     |\n|                               |\n|  [separator]                  |\n|  CONTENT                      |\n|  [doc-text]     Pages         |\n|  [bars-3]       Navigation    |\n|  [paint-brush]  Themes        |\n|                               |\n|  [separator]                  |\n|  [chart-pie]    Analytics     |\n|  [cog-6-tooth]  Settings      |\n|  [squares-2x2]  Apps          |\n|  [code-bracket] Developers    |\n+-------------------------------+\n```\n\n#### Navigation Structure\n\n```\nDashboard                        -> admin.dashboard\nProducts (group label)\n  Products                       -> admin.products.index\n  Collections                    -> admin.collections.index\n  Inventory                      -> admin.inventory.index\nOrders\n  Orders                         -> admin.orders.index\nCustomers\n  Customers                      -> admin.customers.index\nDiscounts\n  Discounts                      -> admin.discounts.index\nContent (group label)\n  Pages                          -> admin.pages.index\n  Navigation                     -> admin.navigation.index\n  Themes                         -> admin.themes.index\nAnalytics                        -> admin.analytics.index\nSettings                         -> admin.settings.index\nApps                             -> admin.apps.index\nDevelopers                       -> admin.developers.index\n```\n\nEach nav item displays an icon (via Flux icon component) and a text label. The active item receives a highlighted background and bold text. All navigation links use Livewire SPA-style page transitions. Groups are visually separated with Flux separator components.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Platform logo and name | `brand` |\n| Navigation icons | `icon` |\n| Group dividers | `separator` |\n\n#### Responsive Behavior\n\n- **Desktop (lg and above):** Sidebar is always visible, fixed left, 256px wide. Main content has a left margin of 256px.\n- **Mobile (below lg):** Sidebar is hidden off-screen. A hamburger button (icon: bars-3) appears in the top bar. When user clicks it, the sidebar slides in as an overlay with a semi-transparent backdrop. Clicking the backdrop or a nav item closes it.\n\n### 1.3 Top Bar\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\TopBar`\n\n**Blade view:** `resources/views/livewire/admin/layout/top-bar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `currentStoreName` | `string` | Name of the active store |\n| `stores` | `Collection` | List of stores the user has access to |\n| `unreadNotificationCount` | `int` | Count of unread notifications |\n\n| Action | Behavior |\n|--------|----------|\n| `switchStore(string $storeId)` | Switches active store in session, redirects to dashboard |\n\n#### Wireframe\n\n```\n+--------------------------------------------------------------------+\n| [hamburger*]  [Store Name v]                  [bell(3)]  [avatar v] |\n+--------------------------------------------------------------------+\n  * hamburger visible on mobile only\n\n  Store Name dropdown:\n    - Store A\n    - Store B\n    - Store C\n\n  Avatar dropdown:\n    - Settings (links to /admin/settings)\n    - [separator]\n    - Log out (POST /logout with CSRF)\n```\n\n#### Interaction Flows\n\n- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.\n- Notification bell shows an unread count badge (positioned absolutely over the icon) when `unreadNotificationCount > 0`.\n- Profile dropdown is triggered by a Flux profile component showing the user's avatar and name.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Store selector | `dropdown` |\n| Hamburger (mobile) | `button` (icon: bars-3) |\n| Notification bell | `button` (icon: bell, variant: ghost) |\n| Notification count | `badge` |\n| User profile trigger | `profile` |\n| Profile menu | `dropdown` |\n| Menu divider | `separator` |\n\n### 1.4 Main Content Area\n\nThe main content area sits to the right of the sidebar and below the top bar. Every page renders within this area.\n\n**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always \"Home\" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > \"Blue T-Shirt\".\n\n**Layout wrapper:** The content area has consistent padding (larger on desktop, smaller on mobile) and a max-width container centered horizontally.\n\n### 1.5 Toast Notifications\n\nA global toast notification system lives in the layout shell. It uses Alpine.js to listen for Livewire dispatched events.\n\n| Property | Value |\n|----------|-------|\n| Event name | `toast` |\n| Payload | `{ type: 'success' / 'error' / 'info', message: string }` |\n| Position | Top-right corner |\n| Auto-dismiss | 5 seconds |\n| Stacking | Supports multiple toasts |\n| Styling | Rounded card, shadow, colored left border (green=success, red=error, blue=info) |\n\nComponents dispatch toasts after successful actions (e.g., \"Product saved successfully.\") or on errors (\"Something went wrong. Please try again.\").\n\n### 1.6 Dark Mode\n\nThe admin supports dark mode via Tailwind's `dark:` variant. The mode follows the user's system preference by default. A toggle can be added in the profile dropdown later. All components and custom markup must include dark mode variants.\n\nDark mode preference is persisted in `localStorage` under the key `theme` with values `light` or `dark`. On page load, the stored preference is applied before first paint to avoid flash of incorrect theme. If no preference is stored, the system preference (`prefers-color-scheme`) is used as default.\n\n---\n\n## 2. Dashboard\n\n**Route:** `GET /admin`\n\n**Livewire component:** `App\\Livewire\\Admin\\Dashboard`\n\n**Blade view:** `resources/views/livewire/admin/dashboard.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Active date range preset: `today`, `last_7_days`, `last_30_days`, `custom` |\n| `customStartDate` | `?string` | Custom range start (Y-m-d) |\n| `customEndDate` | `?string` | Custom range end (Y-m-d) |\n| `totalSales` | `int` | Total sales amount in minor units |\n| `ordersCount` | `int` | Number of orders in range |\n| `averageOrderValue` | `int` | Average order value in minor units |\n| `visitorsCount` | `int` | Unique visitors in range |\n| `salesChange` | `float` | Percentage change vs previous period |\n| `ordersChange` | `float` | Percentage change vs previous period |\n| `aovChange` | `float` | Percentage change vs previous period |\n| `visitorsChange` | `float` | Percentage change vs previous period |\n| `ordersChartData` | `array` | Array of `{date, count}` for last 30 days |\n| `topProducts` | `array` | Top 5 products: `{title, units_sold, revenue}` |\n| `funnelData` | `array` | Funnel steps: `{visits, add_to_cart, checkout_started, checkout_completed}` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Re-fetches all KPI data for the selected range |\n| `loadKpis()` | Queries aggregated order/analytics data |\n| `loadChart()` | Queries daily order counts for chart |\n| `loadTopProducts()` | Queries top 5 products by revenue |\n| `loadFunnel()` | Queries conversion funnel data |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `formattedTotalSales` | Formatted currency string |\n| `formattedAov` | Formatted currency string |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Dashboard                           [Today v] (date range picker) |\n+------------------------------------------------------------------+\n|                                                                    |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|  | Total Sales |  | Orders      |  | Avg Order   |  | Visitors |  |\n|  | $12,345     |  | 156         |  | $79.13      |  | 2,340    |  |\n|  | [+12.3%] ^  |  | [+8.1%] ^  |  | [-2.1%] v  |  | [+5%] ^  |  |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|                                                                    |\n|  +--------------------------------------------------------------+  |\n|  | Orders over time                                              |  |\n|  |  [line/bar chart - daily order counts for selected range]     |  |\n|  +--------------------------------------------------------------+  |\n|                                                                    |\n|  +------------------------------+  +----------------------------+  |\n|  | Top products                 |  | Conversion funnel          |  |\n|  | Product Title | Sold | Rev   |  | Visits       [========] N |  |\n|  | Blue Shirt    | 45   | $900  |  | Add to Cart  [======]   N |  |\n|  | Red Hat       | 32   | $640  |  | Checkout     [====]     N |  |\n|  | Sneakers      | 28   | $2800 |  | Completed    [==]       N |  |\n|  | ...           |      |       |  |                            |  |\n|  +------------------------------+  +----------------------------+  |\n+--------------------------------------------------------------------+\n```\n\n### Layout Description\n\n**Page heading:** \"Dashboard\" displayed as a Flux heading (size xl).\n\n**Date range filter:** Positioned top-right. A Flux dropdown triggered by a button showing the current range label and a calendar icon. Options: Today, Last 7 days, Last 30 days, Custom range (reveals two date input fields when selected).\n\n### KPI Tiles Row\n\nA responsive 4-column grid (1 column on mobile, 2 on sm, 4 on lg).\n\nEach tile is a card containing:\n- A muted-color text label (e.g., \"Total Sales\")\n- A large heading with the formatted number value\n- A row with a Flux badge showing percentage change (green if positive, red if negative) and a directional arrow icon\n\nEach tile shows a loading state (reduced opacity) while data refreshes.\n\n### Orders Chart\n\nA card below the KPI tiles containing:\n- A Flux heading (size lg): \"Orders over time\"\n- A Chart.js canvas (line or bar chart) showing daily order counts\n- Chart.js initialized via Alpine.js; data passed from Livewire\n- A spinner overlay shown during chart data loading\n\n### Top Products Table\n\nA card containing:\n- A Flux heading (size lg): \"Top products\"\n- A plain table with columns: Product Title, Units Sold, Revenue\n- Maximum 5 rows\n- Empty state text: \"No sales data for this period.\"\n\n### Conversion Funnel\n\nA card containing:\n- A Flux heading (size lg): \"Conversion funnel\"\n- Horizontal bar visualization using Tailwind utility widths\n- Each step: label on the left, proportional-width bar on the right, count displayed\n- Steps: Visits, Add to Cart, Checkout Started, Checkout Completed\n- Bars use decreasing widths and progressively darker colors\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` (size xl) |\n| Section titles | `heading` (size lg) |\n| Date range selector | `dropdown`, `button`, `icon` |\n| Date inputs (custom range) | `input` (type date) |\n| KPI labels | `text` |\n| KPI values | `heading` (size xl) |\n| Trend badges | `badge` |\n| Trend arrows | `icon` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | KPI tiles show reduced opacity; chart shows spinner overlay |\n| Empty | \"No sales data for this period.\" in top products; funnel bars at zero width |\n| Populated | All tiles, chart, table, and funnel render with data |\n| Error | Toast notification with error message |\n\n---\n\n## 3. Products List\n\n**Route:** `GET /admin/products`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Index`\n\n**Blade view:** `resources/views/livewire/admin/products/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query, live-debounced at 300ms |\n| `statusFilter` | `string` | Status filter: `all`, `draft`, `active`, `archived` |\n| `typeFilter` | `string` | Product type filter |\n| `selectedIds` | `array<int>` | Array of selected product IDs for bulk actions |\n| `selectAll` | `bool` | Whether all visible rows are selected |\n| `sortField` | `string` | Column to sort by (default: `updated_at`) |\n| `sortDirection` | `string` | `asc` or `desc` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `updatedTypeFilter()` | Resets pagination |\n| `bulkArchive()` | Archives all selected products |\n| `bulkDelete()` | Soft-deletes selected products (with confirmation) |\n| `bulkSetActive()` | Sets selected products to active status |\n| `sortBy(string $field)` | Toggles sort direction or changes sort field |\n| `toggleSelectAll()` | Selects or deselects all visible product IDs |\n| `confirmBulkDelete()` | Opens delete confirmation modal |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |\n| `productTypes` | Distinct product types for the type filter dropdown |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Products                                    [+ Add product]       |\n+------------------------------------------------------------------+\n| [search icon] Search products...   [Status: All v] [Type: All v] |\n+------------------------------------------------------------------+\n| (shown when items selected)                                       |\n| \"3 products selected\"  [Set Active]  [Archive]  [Delete]         |\n+------------------------------------------------------------------+\n| [x] | IMG | Title ^     | Status  | Inventory | Type | Vendor | Updated ^  |\n|-----|-----|-------------|---------|-----------|------|--------|-----------|\n| [ ] | img | Blue Shirt  | Active  | 45        | Tops | Nike   | 2h ago    |\n| [ ] | img | Red Hat     | Draft   | 12        | Hats | Adidas | 1d ago    |\n| [ ] | img | Sneakers    | Active  | 0         | Shoes| Nike   | 3d ago    |\n| ...                                                              |\n+------------------------------------------------------------------+\n| < 1 2 3 ... 10 >  (pagination)                                  |\n+------------------------------------------------------------------+\n```\n\n### Table Column Definitions\n\n| Column | Sortable | Content |\n|--------|----------|---------|\n| Select | No | Checkbox for bulk selection |\n| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |\n| Title | Yes | Product title as a link to the edit page (uses Livewire navigate) |\n| Status | No | Flux badge: draft=zinc/gray, active=green, archived=red |\n| Inventory | Yes | Sum of `inventory_items.quantity_on_hand` across all variants |\n| Type | No | Product type text or \"-\" |\n| Vendor | No | Vendor name text or \"-\" |\n| Updated | Yes (default) | Relative timestamp (e.g., \"2h ago\") |\n\n### Bulk Action Bar\n\nConditionally shown when `count($selectedIds) > 0`. Contains:\n- Text showing \"X products selected\"\n- \"Set Active\" button (ghost variant)\n- \"Archive\" button (ghost variant)\n- \"Delete\" button (danger variant) - opens confirmation modal\n\n### Empty State\n\nWhen no products exist (not filtered, genuinely empty):\n- Centered container with a large muted placeholder icon (cube)\n- Heading: \"Add your first product\"\n- Text: \"Start building your catalog by adding products.\"\n- Primary button: \"Add product\" linking to the create page\n\n### Delete Confirmation Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-bulk-delete` |\n| Max width | md |\n| Heading | \"Delete products?\" |\n| Body text | \"This will archive X product(s). Products with orders cannot be permanently deleted.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `bulkDelete` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Add product button | `button` (variant: primary) |\n| Search field | `input` (icon: magnifying-glass) |\n| Status filter | `select` |\n| Type filter | `select` |\n| Bulk action buttons | `button` (ghost/danger variants) |\n| Selected count text | `text` |\n| Row checkbox | `checkbox` |\n| Status badges | `badge` |\n| Cell text | `text` |\n| Delete confirmation | `modal` |\n| Pagination | Laravel default Tailwind pagination |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | Table body shows reduced opacity |\n| Empty (no products) | Empty state with illustration and CTA |\n| Empty (filtered) | Table with \"No products match your filters\" message |\n| Populated | Table with data rows and pagination |\n\n---\n\n## 4. Product Create/Edit\n\n**Route:** `GET /admin/products/create` and `GET /admin/products/{product}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Form`\n\n**Blade view:** `resources/views/livewire/admin/products/form.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `product` | `?Product` | The product being edited, null for create |\n| `title` | `string` | Product title |\n| `descriptionHtml` | `string` | Product description (HTML) |\n| `status` | `string` | `draft`, `active`, `archived` |\n| `vendor` | `string` | Vendor name |\n| `productType` | `string` | Product type string |\n| `tags` | `string` | Comma-separated tags |\n| `handle` | `string` | URL handle/slug |\n| `publishedAt` | `?string` | Publish date (Y-m-d H:i) |\n| `collectionIds` | `array<int>` | Selected collection IDs |\n| `options` | `array` | Options array: `[{name: string, values: string[]}]` |\n| `variants` | `array` | Variants array: `[{sku, price, compareAtPrice, quantity, requiresShipping, optionValues}]` |\n| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |\n| `newMedia` | `array` | Temporary uploaded files |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(?Product $product)` | Loads product data into properties or sets defaults for create |\n| `save()` | Validates and saves/creates the product, variants, options, and media |\n| `addOption()` | Adds a new empty option `{name: '', values: []}` |\n| `removeOption(int $index)` | Removes an option and regenerates variants |\n| `addOptionValue(int $optionIndex)` | Adds a value to an option |\n| `removeOptionValue(int $optionIndex, int $valueIndex)` | Removes a value |\n| `generateVariants()` | Generates variant combinations from current options |\n| `uploadMedia()` | Handles file upload via Livewire temporary upload |\n| `removeMedia(int $mediaId)` | Removes a media item |\n| `reorderMedia(array $order)` | Updates media positions |\n| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |\n| `deleteProduct()` | Archives the product (with confirmation modal) |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `availableCollections` | All collections for the current store |\n| `isEditing` | `bool` - whether editing or creating |\n\n### Validation Rules\n\n| Field | Rules |\n|-------|-------|\n| `title` | Required, string, max 255 |\n| `descriptionHtml` | Nullable, string, max 65535 |\n| `status` | Required, one of: draft, active, archived |\n| `vendor` | Nullable, string, max 255 |\n| `productType` | Nullable, string, max 255 |\n| `tags` | Nullable, string |\n| `handle` | Required, string, max 255, unique (excluding current product) |\n| `variants.*.sku` | Nullable, string, max 255 |\n| `variants.*.price` | Required, integer, min 0 |\n| `variants.*.compareAtPrice` | Nullable, integer, min 0 |\n| `variants.*.quantity` | Required, integer, min 0 |\n\n### Two-Column Layout Wireframe\n\n```\n+------------------------------------------+-------------------+\n|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|\n+------------------------------------------+-------------------+\n|                                          |                   |\n|  TITLE                                   |  STATUS CARD      |\n|  +------------------------------------+  |  Status: [Draft v]|\n|  | [Short Sleeve T-Shirt           ]  |  |                   |\n|  +------------------------------------+  |  PUBLISHING CARD  |\n|                                          |  Published at:    |\n|  DESCRIPTION                             |  [datetime input]  |\n|  +------------------------------------+  |                   |\n|  | Describe your product...           |  |  ORGANIZATION     |\n|  |                                    |  |  Vendor:          |\n|  |                                    |  |  [Nike          ] |\n|  |                                    |  |  Product type:    |\n|  +------------------------------------+  |  [T-Shirts      ] |\n|                                          |  Tags:            |\n|  MEDIA                                   |  [summer, cotton ] |\n|  +------------------------------------+  |  (comma-separated)|\n|  | +--+ +--+ +--+ +--+               |  |                   |\n|  | |  | |  | |  | |  |  (thumbnails) |  |  COLLECTIONS      |\n|  | +--+ +--+ +--+ +--+               |  |  [x] Summer Sale  |\n|  |                                    |  |  [ ] Winter       |\n|  | [--- Drag & drop or click ---]    |  |  [x] New Arrivals |\n|  +------------------------------------+  |                   |\n|                                          |                   |\n|  VARIANTS                                |                   |\n|  Option name: [Size    ]                 |                   |\n|  Values:      [S, M, L, XL         ]    |                   |\n|  [trash]                                 |                   |\n|                                          |                   |\n|  [+ Add another option]                  |                   |\n|                                          |                   |\n|  Variant | SKU    | Price | Compare | Qty | Ship |          |\n|  S / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|  M / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|  L / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|                                          |                   |\n|  SEO (collapsible)                       |                   |\n|  > Search engine listing                 |                   |\n|    URL handle: [short-sleeve-t-shirt]    |                   |\n+------------------------------------------+-------------------+\n\n+==================================================================+\n| STICKY SAVE BAR (fixed bottom)                                    |\n|                                      [Discard]  [Save]           |\n+==================================================================+\n```\n\n### Left Column Sections\n\n#### Title Input\n- Flux field with label \"Title\"\n- Flux input, placeholder \"Short Sleeve T-Shirt\"\n- Flux error display for validation\n\n#### Description\n- Flux field with label \"Description\"\n- Flux textarea, 8 rows, placeholder \"Describe your product...\"\n- Future enhancement: WYSIWYG toolbar via Alpine.js; baseline is plain textarea\n\n#### Media Section\nA card with heading \"Media\" (size md).\n\n**Upload area:** A dashed-border drop zone with upload icon and text \"Drag and drop images or click to upload\". A hidden file input (multiple, accept images) triggered by clicking the zone.\n\n**Upload progress:** A progress bar shown during file upload.\n\n**Media grid:** A 4-column grid (responsive: 2 on small, 3 on sm, 4 on lg) of thumbnails:\n- Each item: square aspect ratio, object-cover, rounded\n- On hover overlay shows: delete button (X icon, top-right), alt text edit button (pencil icon, bottom-left)\n- Drag-to-reorder supported via Livewire sortable or Alpine.js Sortable\n\n#### Variants Section\nA card with heading \"Variants\" (size md).\n\n**Options builder:** For each option, a row containing:\n- \"Option name\" input field (placeholder: \"Size\")\n- \"Values\" input field (placeholder: \"S, M, L, XL\" - comma-separated)\n- Trash button to remove the option\n\nBelow options: \"Add another option\" ghost button with plus icon.\n\nWhen options change, `generateVariants()` is called automatically via lifecycle hook.\n\n**Variants table:** Shown when variants exist. Scrollable table with inline-editable fields:\n\n| Column | Input Type | Description |\n|--------|-----------|-------------|\n| Variant | Read-only text | Combination name, e.g. \"S / Red\" |\n| SKU | Text input (small) | Stock keeping unit |\n| Price | Number input (small) | Price in major units |\n| Compare at price | Number input (small) | Original price for sale display |\n| Quantity | Number input (small) | On-hand inventory |\n| Requires shipping | Checkbox | Whether variant needs shipping |\n\n#### SEO Section\nA collapsible section (collapsed by default, expands with animation):\n- Toggle button: chevron icon + \"Search engine listing\" text\n- When expanded: URL handle input field with placeholder \"short-sleeve-t-shirt\" and validation error display\n\n### Right Column Sections\n\n#### Status Card\n- Flux field with label \"Status\"\n- Flux select with options: Draft, Active, Archived\n\n#### Publishing Card\n- Flux field with label \"Published at\"\n- Datetime-local input\n\n#### Product Organization Card\n- Vendor: Flux input, placeholder \"Nike\"\n- Product type: Flux input, placeholder \"T-Shirts\"\n- Tags: Flux input, placeholder \"summer, cotton, sale\" with description \"Separate tags with commas\"\n\n#### Collections Card\n- Flux field with label \"Collections\"\n- For each available collection: a row with checkbox and collection title text\n- Checkbox values bound to `collectionIds`\n\n### Sticky Save Bar\n\nFixed at the bottom of the viewport. Appears when changes are detected.\n- Left-aligned to account for sidebar width on desktop\n- \"Discard\" button (ghost variant) - links back to products index\n- \"Save\" button (primary variant) - calls `save()`, shows \"Saving...\" text during loading, disabled while loading\n\n### Delete Confirmation Modal (Edit Only)\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-delete-product` |\n| Max width | md |\n| Heading | \"Delete this product?\" |\n| Body text | \"This product will be archived. Products with existing orders cannot be permanently removed.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `deleteProduct` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Section titles | `heading` (size md) |\n| All form labels | `field`, label via Flux |\n| Text inputs | `input` |\n| Textarea | `textarea` |\n| Status/type selects | `select` |\n| Checkboxes | `checkbox` |\n| Validation errors | `field` error display |\n| Tags description | `field` description |\n| Save/discard buttons | `button` |\n| Delete confirmation | `modal` |\n| Section dividers | `separator` |\n| Variant icons | `icon` |\n| Status text | `text` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading (save) | Save button disabled, text changes to \"Saving...\" |\n| Loading (upload) | Progress bar shown in media section |\n| Create mode | Empty form, no delete button, heading says \"Add product\" |\n| Edit mode | Pre-filled form, delete button visible, heading shows product title |\n| Validation error | Inline error messages below each invalid field |\n\n---\n\n## 5. Collections List and Edit\n\n### 5.1 Collections List\n\n**Route:** `GET /admin/collections`\n\n**Livewire component:** `App\\Livewire\\Admin\\Collections\\Index`\n\n**Blade view:** `resources/views/livewire/admin/collections/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query |\n| `statusFilter` | `string` | `all`, `active`, `archived` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `deleteCollection(int $id)` | Deletes a collection after confirmation |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `collections` | Paginated, filtered, searched. Includes `withCount('products')`. |\n\n#### Layout\n\nSame pattern as Products list:\n- Heading \"Collections\" with \"Add collection\" primary button\n- Search input\n- Status filter dropdown\n\n#### Table Columns\n\n| Column | Content |\n|--------|---------|\n| Title | Link to edit page |\n| Products count | Number of products in collection |\n| Status | Flux badge |\n| Updated date | Relative timestamp |\n\n**Empty state:** \"Create your first collection\" with CTA button.\n\n### 5.2 Collection Edit/Create\n\n**Route:** `GET /admin/collections/create` and `GET /admin/collections/{collection}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Collections\\Form`\n\n**Blade view:** `resources/views/livewire/admin/collections/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collection` | `?Collection` | The collection being edited |\n| `title` | `string` | Collection title |\n| `handle` | `string` | URL slug |\n| `descriptionHtml` | `string` | Collection description |\n| `status` | `string` | `active` or `archived` |\n| `productSearch` | `string` | Search input for product assignment |\n| `assignedProductIds` | `array<int>` | Ordered list of product IDs in this collection |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the collection with product assignments |\n| `addProduct(int $productId)` | Adds a product to the collection |\n| `removeProduct(int $productId)` | Removes a product from the collection |\n| `reorderProducts(array $order)` | Updates product positions |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `searchResults` | Products matching `productSearch` that are not already assigned |\n| `assignedProducts` | Ordered collection of assigned Product models |\n\n#### Layout Wireframe\n\n```\n+------------------------------------------+-------------------+\n|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|\n+------------------------------------------+-------------------+\n|  Title: [Summer Collection         ]     | Status: [Active v] |\n|  Handle: [summer-collection        ]     |                   |\n|  Description:                            |                   |\n|  [textarea...                      ]     |                   |\n|                                          |                   |\n|  PRODUCTS                                |                   |\n|  Search: [Search products...       ]     |                   |\n|  +----------------------------------+   |                   |\n|  | Blue Shirt           [Add]       |   |                   |\n|  | Red Hat              [Add]       |   |                   |\n|  +----------------------------------+   |                   |\n|                                          |                   |\n|  Assigned products:                      |                   |\n|  [drag] [img] Sneakers         [remove]  |                   |\n|  [drag] [img] Blue Shirt       [remove]  |                   |\n+------------------------------------------+-------------------+\n\n+==================================================================+\n| STICKY SAVE BAR                          [Discard]  [Save]       |\n+==================================================================+\n```\n\n**Left column:**\n- Title input\n- Handle input\n- Description textarea\n- Products assignment section:\n  - Search input with live debounce (300ms)\n  - Search results dropdown (conditionally shown): list of matching products with \"Add\" button\n  - Assigned products list: each row shows product title, thumbnail, and a remove button. Rows are sortable via drag-to-reorder.\n\n**Right column:**\n- Status: Flux select with Active / Archived\n- Sticky save bar (same pattern as product form)\n\n---\n\n## 6. Inventory Management\n\n**Route:** `GET /admin/inventory`\n\n**Livewire component:** `App\\Livewire\\Admin\\Inventory\\Index`\n\n**Blade view:** `resources/views/livewire/admin/inventory/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by product title or SKU |\n| `stockFilter` | `string` | `all`, `in_stock`, `low_stock`, `out_of_stock` |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `inventoryItems` | Paginated inventory items with variant and product eager loaded, filtered by search and stock level |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Inventory                                                         |\n+------------------------------------------------------------------+\n| [search] Search by product or SKU   [Stock: All v]               |\n+------------------------------------------------------------------+\n| Product | Variant | SKU     | On Hand | Reserved | Policy | Act  |\n|---------|---------|---------|---------|----------|--------|------|\n| Shirt   | S/Blue  | SH-001  | [45]    | 3        | deny   | ...  |\n| Shirt   | M/Blue  | SH-002  | [0]     | 0        | deny   | ...  |\n| Hat     | Red     | HT-001  | [12]    | 1        | cont   | ...  |\n+------------------------------------------------------------------+\n```\n\n### Layout\n\n- Heading \"Inventory\"\n- Search input and stock filter dropdown\n- Table columns: Product, Variant, SKU, On Hand (inline-editable number input), Reserved, Policy (shown as Flux badge: \"deny\" or \"continue\"), Actions\n\n---\n\n## 7. Orders List\n\n**Route:** `GET /admin/orders`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Index`\n\n**Blade view:** `resources/views/livewire/admin/orders/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by order number or customer email |\n| `statusFilter` | `string` | `all`, `pending`, `paid`, `fulfilled`, `cancelled`, `refunded` |\n| `sortField` | `string` | Sort column (default: `placed_at`) |\n| `sortDirection` | `string` | `asc` or `desc` (default: `desc`) |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `sortBy(string $field)` | Updates sort |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `orders` | Paginated query with search, status filter, sorting. Eager loads `customer`. |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Orders                                                            |\n+------------------------------------------------------------------+\n| [search] Search by order # or email...                           |\n+------------------------------------------------------------------+\n| [All] [Pending] [Paid] [Fulfilled] [Cancelled] [Refunded]       |\n+------------------------------------------------------------------+\n| Order #  | Date           | Customer  | Payment   | Fulfill | Total  |\n|----------|----------------|-----------|-----------|---------|--------|\n| #1001    | Jan 15, 2:30 PM| John Doe  | Paid      | Unful.  | $131.36|\n| #1000    | Jan 14, 9:15 AM| Guest     | Pending   | Unful.  | $45.00 |\n+------------------------------------------------------------------+\n| < 1 2 3 >                                                        |\n+------------------------------------------------------------------+\n```\n\n### Filter Tabs\n\nA horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.\n\n### Table Column Definitions\n\n| Column | Content |\n|--------|---------|\n| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n| Customer | Customer name or \"Guest\" |\n| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n| Total | Formatted currency amount |\n\nPagination below the table.\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Search field | `input` |\n| Status badges | `badge` |\n| Cell text | `text` |\n\n---\n\n## 8. Order Detail\n\n**Route:** `GET /admin/orders/{order}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n\n**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `confirmPayment()` | Confirms bank transfer payment received (transitions financial_status to `paid`, commits inventory, dispatches `OrderPaid`) |\n| `createFulfillment()` | Creates a fulfillment record for selected lines (enforces fulfillment guard) |\n| `markAsShipped(int $fulfillmentId)` | Transitions fulfillment to `shipped`, sets `shipped_at` |\n| `markAsDelivered(int $fulfillmentId)` | Transitions fulfillment to `delivered`, sets `delivered_at` |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |\n\n### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n| Jan 15, 2024 2:30 PM                        | John Doe           |\n|                                              | john@example.com   |\n| [Create fulfillment]  [Refund]               | > View customer    |\n|                                              |--------------------|\n| TIMELINE                                     | SHIPPING ADDRESS   |\n| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n| |                                            | Springfield, IL    |\n| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n| |                                            | United States      |\n| o  (more events...)                          |--------------------|\n|                                              | BILLING ADDRESS    |\n| ORDER LINES                                  | 123 Main St        |\n| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n|                                              |                    |\n|                    Subtotal     $140.00       |                    |\n|                    Discount     -$12.00       |                    |\n|                    Shipping      $5.00        |                    |\n|                    Tax          $18.36        |                    |\n|                    Total       $151.36        |                    |\n|                                              |                    |\n| PAYMENT DETAILS                              |                    |\n| Provider: Stripe  [Paid]                     |                    |\n| Amount: $151.36                              |                    |\n| Payment ID: pi_xxx...                        |                    |\n+----------------------------------------------+--------------------+\n```\n\n### Left Column Sections\n\n#### Order Heading\n- Order number as a large heading\n- Financial status badge (color mapped: paid=green, default=zinc)\n- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n- Placed-at date as muted text\n\n#### Action Buttons\nShown below the heading:\n- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n- \"Refund\" button (ghost variant, shown when order is paid)\n\n#### Timeline\nA vertical timeline with left border line and dot markers:\n- Each event: title text (bold) and timestamp text (small, muted)\n- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n\n#### Order Lines Table\n\n| Column | Content |\n|--------|---------|\n| Image | 40x40 product thumbnail |\n| Product | Title and SKU |\n| Quantity | Unit count |\n| Unit Price | Formatted amount |\n| Total | Line total formatted |\n\n#### Order Summary\nA right-aligned summary block below the lines table:\n- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n- Each row is a flex container with label left and amount right\n\n#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)\n\n### Right Column Sections\n\n#### Customer Card\n- Heading \"Customer\" (size md)\n- Separator\n- Customer name (or \"Guest\")\n- Customer email (muted text)\n- \"View customer\" link (shown only if customer exists, links to customer detail page)\n\n#### Shipping Address Card\n- Heading \"Shipping address\" (size md)\n- Separator\n- Full address rendered from shipping_address_json: line1, city/state/zip, country\n\n#### Billing Address Card\nSame pattern as shipping address, using billing_address_json.\n\n### Fulfillment Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-fulfillment` |\n| Max width | lg |\n| Heading | \"Create fulfillment\" |\n\n**Content:**\n- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n- Separator\n- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n- Tracking number input field\n- Tracking URL input field (type: url)\n- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n\n### Refund Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-refund` |\n| Max width | lg |\n| Heading | \"Refund order\" |\n\n**Content:**\n- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n- Separator\n- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n- Cancel button (ghost) and \"Create refund\" button (danger)\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Order number heading | `heading` |\n| Status badges | `badge` |\n| Action buttons | `button` |\n| Section headings | `heading` (size md) |\n| Section dividers | `separator` |\n| Text content | `text` |\n| Both modals | `modal` |\n| Modal form fields | `field`, `input`, `textarea` |\n| Line checkboxes | `checkbox` |\n| Modal buttons | `button` |\n| Icons | `icon` |\n| Callout messages | `callout` |\n\n---\n\n## 9. Customers List and Detail\n\n### 9.1 Customers List\n\n**Route:** `GET /admin/customers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by name or email |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `customers` | Paginated, searched customers with `withCount('orders')` and `withSum('orders', 'total_amount')` |\n\n#### Layout\n\n- Heading \"Customers\"\n- Search input\n- Table columns: Name, Email, Orders count, Total spent (formatted), Created date\n- Each row links to customer detail\n\n### 9.2 Customer Detail\n\n**Route:** `GET /admin/customers/{customer}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Show`\n\n**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `customer` | `Customer` | The customer model |\n| `editingAddress` | `?CustomerAddress` | Address currently being edited |\n| `addressLabel` | `string` | Label for address form |\n| `addressJson` | `array` | Address fields being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Customer $customer)` | Loads customer with orders and addresses |\n| `openAddressForm(?CustomerAddress $address)` | Opens address modal for create/edit |\n| `saveAddress()` | Saves the address |\n| `deleteAddress(int $addressId)` | Deletes an address |\n| `setDefaultAddress(int $addressId)` | Sets address as default |\n\n#### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| CUSTOMER INFO                                | ADDRESSES          |\n| Name: Jane Smith                             | Home (default)     |\n| Email: jane@example.com                      | 123 Main St        |\n| Created: Jan 1, 2024                         | Springfield, IL    |\n| Marketing: [Opted In]                        | [Edit] [Delete]    |\n|                                              |                    |\n| ORDER HISTORY                                | Office             |\n| Order # | Date      | Status | Total         | 456 Corp Ave       |\n| #1005   | Jan 20    | Paid   | $89.00        | Chicago, IL        |\n| #1001   | Jan 15    | Paid   | $131.36       | [Edit] [Delete]    |\n| < 1 2 >                                     |                    |\n|                                              | [+ Add address]    |\n+----------------------------------------------+--------------------+\n```\n\n**Left column:**\n- Customer info card: Name, email, created date, marketing opt-in status (Flux badge)\n- Order history table: columns Order #, Date, Status, Total. Each order links to order detail. Paginated.\n\n**Right column:**\n- Addresses card: List of addresses, each showing label, full address text, default badge (if `is_default`), Edit and Delete buttons\n- \"Add address\" button opens address modal\n\n#### Address Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `address-form` |\n| Max width | md |\n| Heading | \"Edit address\" or \"Add address\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"Home, Office...\")\n- Address line 1 input\n- Address line 2 input\n- City and State/Province inputs (side by side, 2-column grid)\n- ZIP/Postal code and Country select (side by side, 2-column grid)\n- Cancel button (ghost) and \"Save\" button (primary)\n\n---\n\n## 10. Discounts List and Form\n\n### 10.1 Discounts List\n\n**Route:** `GET /admin/discounts`\n\n**Livewire component:** `App\\Livewire\\Admin\\Discounts\\Index`\n\n**Blade view:** `resources/views/livewire/admin/discounts/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by discount code |\n| `statusFilter` | `string` | `all`, `active`, `expired`, `scheduled` |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `discounts` | Paginated, filtered discounts |\n\n#### Layout\n\n- Heading \"Discounts\" with \"Create discount\" primary button\n- Search input and status filter\n\n#### Table Columns\n\n| Column | Content |\n|--------|---------|\n| Code | Discount code or \"Automatic\" label |\n| Type | Flux badge: \"Code\" or \"Automatic\" |\n| Value | e.g., \"10%\" or \"$5.00\" or \"Free shipping\" |\n| Usage | \"3 / 100\" (used / limit) or \"3 / unlimited\" |\n| Status | Flux badge: active=green, expired=red, scheduled=yellow |\n| Dates | Start and end dates |\n\n### 10.2 Discount Form\n\n**Route:** `GET /admin/discounts/create` and `GET /admin/discounts/{discount}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Discounts\\Form`\n\n**Blade view:** `resources/views/livewire/admin/discounts/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `discount` | `?Discount` | The discount being edited |\n| `type` | `string` | `code` or `automatic` |\n| `code` | `string` | Discount code (if type is code) |\n| `valueType` | `string` | `percent`, `fixed`, `free_shipping` |\n| `valueAmount` | `?int` | Discount value (percentage or fixed amount) |\n| `minimumPurchaseAmount` | `?int` | Minimum cart value for eligibility |\n| `specificProductIds` | `array<int>` | Specific products the discount applies to |\n| `specificCollectionIds` | `array<int>` | Specific collections the discount applies to |\n| `usageLimit` | `?int` | Maximum total uses |\n| `onePerCustomer` | `bool` | Limit to one use per customer |\n| `startsAt` | `string` | Start datetime |\n| `endsAt` | `?string` | End datetime (optional) |\n| `isActive` | `bool` | Whether the discount is active |\n| `productSearch` | `string` | Search for products to add |\n| `collectionSearch` | `string` | Search for collections to add |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the discount |\n| `generateCode()` | Auto-generates a random discount code |\n| `addProduct(int $productId)` | Adds product to specific products list |\n| `removeProduct(int $productId)` | Removes product |\n| `addCollection(int $collectionId)` | Adds collection to specific collections |\n| `removeCollection(int $collectionId)` | Removes collection |\n\n#### Layout Wireframe (Single Column)\n\n```\n+==================================================================+\n| TYPE CARD                                                         |\n|  ( ) Discount code - Customers enter a code at checkout          |\n|  (o) Automatic discount - Applied automatically at checkout      |\n+==================================================================+\n| CODE CARD (shown when type = \"code\")                             |\n|  Discount code: [SUMMER20             ]  [Generate]              |\n+==================================================================+\n| VALUE CARD                                                        |\n|  ( ) Percentage                                                   |\n|  (o) Fixed amount                                                 |\n|  ( ) Free shipping                                                |\n|                                                                   |\n|  Amount: [5.00                        ] (hidden for free shipping)|\n+==================================================================+\n| CONDITIONS CARD                                                   |\n|  Minimum purchase amount: [0.00       ]                          |\n|  (Leave empty for no minimum)                                     |\n|                                                                   |\n|  Specific products:                                               |\n|  [Search products...          ]                                   |\n|  +---results dropdown---+                                         |\n|  Selected: Blue Shirt [x]  Red Hat [x]                           |\n|                                                                   |\n|  Specific collections: (same pattern as products)                 |\n+==================================================================+\n| USAGE LIMITS CARD                                                 |\n|  Total usage limit: [100              ] (or leave blank)         |\n|  [x] Limit to one use per customer                               |\n+==================================================================+\n| ACTIVE DATES CARD                                                 |\n|  Start date: [2024-01-15 00:00       ]                           |\n|  End date:   [2024-02-15 00:00       ] (or leave blank)          |\n+==================================================================+\n| STATUS                                                            |\n|  [switch: ON]  Active                                             |\n+==================================================================+\n| STICKY SAVE BAR                          [Discard]  [Save]       |\n+==================================================================+\n```\n\n#### Section Details\n\n**Type section:** Two radio buttons - \"Discount code\" (description: \"Customers enter a code at checkout\") and \"Automatic discount\" (description: \"Applied automatically at checkout\"). Selecting type is live-bound.\n\n**Code input:** Shown only when type = \"code\". Text input with placeholder \"SUMMER20\" and a \"Generate\" ghost button that auto-generates a random code.\n\n**Value section:** Three radio buttons for value type: Percentage, Fixed amount, Free shipping. When not free shipping, a number input appears for the amount (label changes based on type: \"Percentage\" or \"Amount\").\n\n**Conditions section:**\n- Minimum purchase amount: number input with description \"Leave empty for no minimum\"\n- Specific products: search input with live debounce. Results appear in a scrollable dropdown. Selected products shown as a list with remove (X) buttons.\n- Specific collections: same pattern as products.\n\n**Usage limits section:**\n- Total usage limit number input (placeholder: \"Unlimited\")\n- \"Limit to one use per customer\" checkbox\n\n**Active dates section:**\n- Start date: datetime-local input\n- End date: datetime-local input with description \"Leave empty for no end date\"\n\n**Status:** Toggle switch with text label \"Active\" or \"Disabled\".\n\nSticky save bar at the bottom (same pattern as product form).\n\n---\n\n## 11. Settings Pages\n\n### 11.1 General Settings\n\n**Route:** `GET /admin/settings`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\General`\n\n**Blade view:** `resources/views/livewire/admin/settings/general.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `storeName` | `string` | Store display name |\n| `storeHandle` | `string` | Store handle (read-only or with warning) |\n| `defaultCurrency` | `string` | Currency code (e.g., \"EUR\") |\n| `defaultLocale` | `string` | Locale code (e.g., \"en\") |\n| `timezone` | `string` | Timezone string |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves store settings |\n\n#### Layout\n\nTwo-column layout per section: left side has section label and description, right side has form fields.\n\n**Store details section:**\n- Left: Heading \"Store details\" + description \"Basic information about your store.\"\n- Right: Store name input, Store handle input (disabled, with description: \"The store handle cannot be changed after creation.\")\n\nSeparator between sections.\n\n**Defaults section:**\n- Left: Heading \"Defaults\" + description \"Currency, language, and timezone settings.\"\n- Right:\n  - Default currency: Select (EUR/USD/GBP and more)\n  - Default locale: Select (English/German/French and more)\n  - Timezone: Select (populated from PHP timezone list)\n\nSave button at the bottom (primary variant, right-aligned).\n\n### 11.2 Domains Settings\n\nDomains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Domains`\n\n**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `domains` | `Collection` | Store domains |\n| `newHostname` | `string` | Hostname for new domain |\n| `newType` | `string` | Type for new domain: `storefront`, `admin`, `api` |\n\n| Action | Behavior |\n|--------|----------|\n| `addDomain()` | Creates a new domain record |\n| `removeDomain(int $domainId)` | Removes a domain |\n| `setPrimary(int $domainId)` | Sets domain as primary |\n\n#### Layout\n\n- Heading \"Domains\"\n- Table of existing domains:\n\n| Column | Content |\n|--------|---------|\n| Hostname | Domain hostname |\n| Type | Flux badge: storefront/admin/api |\n| Primary | Flux badge (green) \"Primary\" if `is_primary` |\n| TLS | Flux badge showing TLS mode |\n| Actions | \"Set Primary\" button, \"Delete\" button |\n\n- \"Add domain\" button opens a modal\n\n#### Add Domain Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `add-domain` |\n| Max width | md |\n| Heading | \"Add domain\" |\n\n**Form fields:**\n- Hostname input (placeholder: \"shop.example.com\")\n- Type select: Storefront, Admin, API\n- Cancel button (ghost) and \"Add domain\" button (primary)\n\n### 11.3 Shipping Settings\n\n**Route:** `GET /admin/settings/shipping`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Shipping`\n\n**Blade view:** `resources/views/livewire/admin/settings/shipping.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `zones` | `Collection` | Shipping zones with rates |\n| `editingZone` | `?ShippingZone` | Zone being edited |\n| `zoneName` | `string` | Name for zone form |\n| `zoneCountries` | `array` | Selected countries for zone |\n| `editingRate` | `?ShippingRate` | Rate being edited |\n| `rateName` | `string` | Rate name |\n| `rateType` | `string` | `flat`, `weight`, `price`, `carrier` |\n| `rateConfig` | `array` | Rate configuration (depends on type) |\n| `rateActive` | `bool` | Whether rate is active |\n| `testAddress` | `array` | Address fields for test tool |\n| `testResult` | `?array` | Result of test address lookup |\n\n| Action | Behavior |\n|--------|----------|\n| `openZoneModal(?ShippingZone $zone)` | Opens zone create/edit modal |\n| `saveZone()` | Saves shipping zone |\n| `deleteZone(int $zoneId)` | Deletes a shipping zone |\n| `openRateModal(int $zoneId, ?ShippingRate $rate)` | Opens rate create/edit modal |\n| `saveRate()` | Saves shipping rate |\n| `deleteRate(int $rateId)` | Deletes a rate |\n| `testShippingAddress()` | Tests which zone/rate matches a given address |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| Shipping                                         [+ Add zone]    |\n+==================================================================+\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | ZONE: Domestic                            [Edit] [Delete]    | |\n| | Countries: US, CA                                             | |\n| |                                                               | |\n| | Name      | Type    | Config           | Active | Actions    | |\n| |-----------|---------|------------------|--------|------------|  |\n| | Standard  | flat    | $5.00            | [on]   | [Ed] [Del]| |\n| | Express   | flat    | $15.00           | [on]   | [Ed] [Del]| |\n| | [+ Add rate]                                                  | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | ZONE: Europe                              [Edit] [Delete]    | |\n| | Countries: DE, FR, GB, ...                                    | |\n| | (rates table...)                                              | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n| TEST SHIPPING ADDRESS                                             |\n| Country: [v]  State: [    ]  City: [    ]  ZIP: [    ]           |\n| [Test]                                                            |\n| Result: Matched zone: Domestic                                    |\n|         Standard - $5.00 / Express - $15.00                      |\n+==================================================================+\n```\n\nEach zone is displayed as a card with:\n- Zone name heading and Edit/Delete buttons in the header\n- Countries list as muted text\n- Rates table inside the zone card with columns: Name, Type (as badge), Config summary, Active (toggle switch), Actions (Edit/Delete buttons)\n- \"Add rate\" button at the bottom of each zone card\n\n\"Add zone\" button at the top of the page.\n\n#### Zone Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `zone-form` |\n| Max width | md |\n| Heading | \"Edit shipping zone\" or \"Add shipping zone\" (dynamic) |\n\n**Form fields:**\n- Zone name input (placeholder: \"Domestic, Europe, International...\")\n- Countries: scrollable checklist of all available countries with checkboxes\n- Cancel button (ghost) and \"Save zone\" button (primary)\n\n#### Rate Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `rate-form` |\n| Max width | md |\n| Heading | \"Edit shipping rate\" or \"Add shipping rate\" (dynamic) |\n\n**Form fields:**\n- Rate name input (placeholder: \"Standard, Express...\")\n- Rate type select: Flat rate, Weight-based, Price-based, Carrier-calculated\n- Dynamic config fields based on rate type:\n  - **Flat:** Price input\n  - **Weight-based:** Min weight (g) input, Max weight (g) input, Price input\n  - **Price-based:** Min order amount input, Max order amount input, Price input\n  - **Carrier:** Info callout: \"Carrier-calculated rates require a carrier integration to be configured.\"\n- Active toggle switch with \"Active\" text label\n- Cancel button (ghost) and \"Save rate\" button (primary)\n\n#### Test Shipping Address Tool\n\nA card section at the bottom of the page:\n- Heading \"Test shipping address\"\n- Description: \"Enter an address to see which shipping zone and rates match.\"\n- 2-column grid of fields: Country (select), State/Region (input), City (input), ZIP/Postal code (input)\n- \"Test\" button\n- Result area (shown after test):\n  - If matched: \"Matched zone: [zone name]\" and list of available rates with prices\n  - If no match: Warning callout \"No shipping zone matches this address.\"\n\n### 11.4 Tax Settings\n\n**Route:** `GET /admin/settings/taxes`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Taxes`\n\n**Blade view:** `resources/views/livewire/admin/settings/taxes.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `mode` | `string` | `manual` or `provider` |\n| `pricesIncludeTax` | `bool` | Whether prices include tax |\n| `provider` | `string` | Provider name (e.g., `stripe_tax`) |\n| `providerApiKey` | `string` | API key for tax provider |\n| `manualRates` | `array` | Array of `{zone_name, rate_percentage}` |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Saves tax settings |\n| `addManualRate()` | Adds a new empty rate row |\n| `removeManualRate(int $index)` | Removes a manual rate |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| TAXES                                                             |\n+==================================================================+\n| MODE SELECTION                                                    |\n|  ( ) Manual tax rates - Define tax rates per zone manually       |\n|  (o) Tax provider - Use an automated tax calculation service     |\n+==================================================================+\n| MANUAL RATES TABLE (shown when mode = \"manual\")                  |\n| Zone name       | Rate (%)    |                                   |\n|-----------------|-------------|                                   |\n| [EU           ] | [19.00    ] | [trash]                          |\n| [US-CA        ] | [7.25     ] | [trash]                          |\n| [+ Add rate]                                                      |\n+==================================================================+\n| PROVIDER CONFIG (shown when mode = \"provider\")                   |\n|  Provider: [Stripe Tax v]                                        |\n|  API key:  [*************]                                       |\n+==================================================================+\n| [separator]                                                       |\n| TAX-INCLUSIVE TOGGLE                                              |\n|  [switch: ON]  Prices include tax                                |\n|  Description: When enabled, the listed price includes tax.       |\n|  Tax is calculated backwards from the price.                      |\n+==================================================================+\n| [Save]                                                            |\n+==================================================================+\n```\n\n**Mode selection:** Two radio buttons - \"Manual tax rates\" (description: \"Define tax rates per zone manually\") and \"Tax provider\" (description: \"Use an automated tax calculation service\"). Live-bound.\n\n**Manual rates table:** Shown when mode = \"manual\". Inline-editable table with columns: Zone name (text input), Rate % (number input with step 0.01). Each row has a trash button. \"Add rate\" ghost button with plus icon.\n\n**Provider config:** Shown when mode = \"provider\". Provider select (Stripe Tax / None) and API key password input.\n\n**Tax-inclusive toggle:** Toggle switch with \"Prices include tax\" label and description text explaining the behavior.\n\nSave button at the bottom.\n\n---\n\n## 12. Themes Management\n\n### 12.1 Themes List\n\n**Route:** `GET /admin/themes`\n\n**Livewire component:** `App\\Livewire\\Admin\\Themes\\Index`\n\n**Blade view:** `resources/views/livewire/admin/themes/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `themes` | `Collection` | All themes for the store |\n\n| Action | Behavior |\n|--------|----------|\n| `publishTheme(int $themeId)` | Publishes a theme (sets as active) |\n| `duplicateTheme(int $themeId)` | Creates a copy of the theme |\n| `deleteTheme(int $themeId)` | Deletes a theme |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| Themes                                                            |\n+==================================================================+\n|                                                                   |\n| +------------------+ +------------------+ +------------------+   |\n| | [preview image]  | | [preview image]  | | [preview image]  |   |\n| |                  | |                  | |                  |   |\n| | My Theme   v1.0  | | Dark Theme v1.2  | | Minimal    v2.0  |   |\n| | [Published]      | | [Draft]          | | [Draft]          |   |\n| |                  | |                  | |                  |   |\n| | [Customize] [...] | | [Customize] [...] | | [Customize] [...] |   |\n| +------------------+ +------------------+ +------------------+   |\n|                                                                   |\n+==================================================================+\n```\n\n**Theme grid:** Responsive 3-column grid (1 on mobile, 2 on md, 3 on lg).\n\nEach theme card contains:\n- **Thumbnail area:** Aspect-video container with theme preview image or placeholder\n- **Info section:**\n  - Theme name (heading, size md) and version text\n  - Status badge (published=green, draft=zinc)\n- **Actions:**\n  - \"Customize\" primary button (links to theme editor)\n  - More actions dropdown (triggered by ellipsis icon button):\n    - \"Preview\" menu item\n    - \"Publish\" menu item (hidden if already published)\n    - \"Duplicate\" menu item\n    - Separator\n    - \"Delete\" menu item (styled red/danger)\n\nThe published theme card has a blue ring border highlight.\n\n### 12.2 Theme Editor\n\n**Route:** `GET /admin/themes/{theme}/editor`\n\n**Livewire component:** `App\\Livewire\\Admin\\Themes\\Editor`\n\n**Blade view:** `resources/views/livewire/admin/themes/editor.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `theme` | `Theme` | The theme being edited |\n| `sections` | `array` | List of theme sections from settings schema |\n| `selectedSection` | `?string` | Currently selected section key |\n| `sectionSettings` | `array` | Settings values for the selected section |\n| `previewUrl` | `string` | URL for the live preview iframe |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Theme $theme)` | Loads theme and its sections |\n| `selectSection(string $sectionKey)` | Changes the selected section and loads its settings |\n| `updateSetting(string $key, mixed $value)` | Updates a setting value |\n| `save()` | Saves all section settings to theme_settings |\n| `publish()` | Publishes the theme |\n| `refreshPreview()` | Reloads the preview iframe |\n\n#### Layout Wireframe (Full Width, Three-Panel)\n\n```\n+==================================================================+\n| [<- Back to themes]                     [Save]  [Save & publish] |\n+==================================================================+\n| SECTIONS   |  LIVE PREVIEW (iframe)       |  SETTINGS PANEL      |\n| (264px)    |  (flexible width)            |  (320px)             |\n|            |                              |                      |\n| Header     |  +------------------------+ |  Header Settings     |\n| Hero       |  |                        | |  [separator]         |\n| Features*  |  |  Live storefront       | |                      |\n| Products   |  |  preview rendered      | |  Logo text:          |\n| Footer     |  |  in an iframe          | |  [My Store        ]  |\n|            |  |                        | |                      |\n|            |  |                        | |  Background color:   |\n|            |  |                        | |  [color picker]      |\n|            |  |                        | |                      |\n|            |  |                        | |  Show search:        |\n|            |  |                        | |  [x] checkbox        |\n|            |  |                        | |                      |\n|            |  +------------------------+ |  Layout:             |\n|            |                              |  [Centered       v]  |\n+==================================================================+\n  * = currently selected section (highlighted)\n```\n\n**Full-width layout** (no standard admin shell padding). Three-panel layout:\n\n**Top toolbar:** A horizontal bar with:\n- \"Back to themes\" button (ghost, with left arrow icon) linking to themes index\n- \"Save\" ghost button and \"Save and publish\" primary button\n\n**Left panel (264px):** Section list\n- Heading \"Sections\"\n- Vertical list of section buttons\n- Active section gets highlighted background and bold text\n- Clicking a section calls `selectSection()`\n\n**Center panel (flexible):** Live preview\n- Background color behind the iframe\n- Full-height iframe loading the storefront preview URL\n- Iframe has rounded border and shadow\n\n**Right panel (320px):** Settings form\n- Shows settings for the selected section\n- Heading with section label\n- Separator\n- Dynamic form fields based on section field definitions:\n\n| Field Type | UI Element |\n|-----------|------------|\n| `text` | Flux input (live-debounced at 500ms) |\n| `textarea` | Flux textarea (live-debounced at 500ms, 3 rows) |\n| `color` | Native color picker input |\n| `select` | Flux select with options from field definition |\n| `checkbox` | Flux checkbox |\n\n- When no section is selected: centered text \"Select a section to edit its settings.\"\n\n---\n\n## 13. Pages Management\n\n### 13.1 Pages List\n\n**Route:** `GET /admin/pages`\n\n**Livewire component:** `App\\Livewire\\Admin\\Pages\\Index`\n\n**Blade view:** `resources/views/livewire/admin/pages/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by page title |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `pages` | Paginated pages for the current store |\n\n#### Layout\n\n- Heading \"Pages\" with \"Add page\" primary button\n- Search input\n- Table columns: Title (link to edit), Handle, Status (Flux badge), Updated date\n- Pagination\n\n### 13.2 Page Form\n\n**Route:** `GET /admin/pages/create` and `GET /admin/pages/{page}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Pages\\Form`\n\n**Blade view:** `resources/views/livewire/admin/pages/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `page` | `?Page` | Page being edited |\n| `title` | `string` | Page title |\n| `handle` | `string` | URL handle |\n| `bodyHtml` | `string` | Page body content (HTML) |\n| `status` | `string` | `draft` or `active` |\n| `publishedAt` | `?string` | Publish date |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the page |\n| `deletePage()` | Deletes the page |\n\n#### Layout (Two-Column)\n\n**Left column (2/3):**\n- Title: Flux input\n- Handle: Flux input\n- Body: Flux textarea (16 rows)\n\n**Right column (1/3):**\n- Status: Flux select with Draft/Active\n- Published at: Datetime-local input\n\nSticky save bar (same pattern as other forms).\n\n---\n\n## 14. Navigation Management\n\n**Route:** `GET /admin/navigation`\n\n**Livewire component:** `App\\Livewire\\Admin\\Navigation\\Index`\n\n**Blade view:** `resources/views/livewire/admin/navigation/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `menus` | `Collection` | Navigation menus (Main Menu, Footer Menu) |\n| `editingMenu` | `?NavigationMenu` | Menu being edited |\n| `menuItems` | `array` | Nested array of menu items for the editing menu |\n| `editingItem` | `?array` | Item currently being edited in the modal |\n| `editingItemIndex` | `?int` | Index of item being edited |\n| `itemLabel` | `string` | Item label |\n| `itemType` | `string` | `link`, `page`, `collection`, `product` |\n| `itemUrl` | `string` | URL if type is link |\n| `itemResourceId` | `?int` | Resource ID if type is page/collection/product |\n\n| Action | Behavior |\n|--------|----------|\n| `selectMenu(int $menuId)` | Loads a menu for editing |\n| `addItem()` | Opens the item form modal in create mode |\n| `editItem(int $index)` | Opens the item form modal in edit mode |\n| `saveItem()` | Saves the item (add or update) |\n| `removeItem(int $index)` | Removes an item |\n| `reorderItems(array $order)` | Updates item positions (supports nesting) |\n| `saveMenu()` | Persists all menu items |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Navigation                                                        |\n+==================================================================+\n|                                                                   |\n| MENUS                                                             |\n| +----------------------------+  +----------------------------+   |\n| | Main Menu        [Edit]    |  | Footer Menu       [Edit]   |   |\n| +----------------------------+  +----------------------------+   |\n|                                                                   |\n| MENU EDITOR (shown when a menu is selected)                      |\n| +--------------------------------------------------------------+ |\n| | Main Menu                                    [+ Add item]    | |\n| |                                                               | |\n| | [drag] Home               link: /            [edit] [trash]  | |\n| | [drag] Products           collection: All    [edit] [trash]  | |\n| | [drag] About Us           page: About        [edit] [trash]  | |\n| | [drag] Contact            link: /contact     [edit] [trash]  | |\n| |                                                               | |\n| |                                            [Save menu]       | |\n| +--------------------------------------------------------------+ |\n+==================================================================+\n```\n\n**Menu list:** Each menu displayed as a card with title and \"Edit\" button.\n\n**Menu editor (shown when a menu is selected):**\n- Menu title as heading\n- \"Add item\" button (ghost, plus icon) in the header\n- Sortable list of menu items, each row containing:\n  - Drag handle (bars icon)\n  - Item label (bold text)\n  - Item type and target (small, muted text: e.g., \"link: /about\" or \"collection: Summer Sale\")\n  - Edit button (pencil icon) and Delete button (trash icon)\n- \"Save menu\" primary button at the bottom\n\n#### Menu Item Form Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `item-form` |\n| Max width | md |\n| Heading | \"Edit menu item\" or \"Add menu item\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"About Us\")\n- Type select: Custom link, Page, Collection, Product (live-bound)\n- Conditional fields based on type:\n  - **Custom link:** URL input (placeholder: \"https://...\")\n  - **Page/Collection/Product:** Resource select dropdown populated with available resources of that type\n- Cancel button (ghost) and \"Save item\" button (primary)\n\n---\n\n## 15. Apps Page\n\n**Route:** `GET /admin/apps`\n\n**Livewire component:** `App\\Livewire\\Admin\\Apps\\Index`\n\n**Blade view:** `resources/views/livewire/admin/apps/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `installedApps` | `Collection` | Apps installed for this store |\n\n| Action | Behavior |\n|--------|----------|\n| `uninstallApp(int $appId)` | Uninstalls an app (with confirmation) |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Apps                                                              |\n+==================================================================+\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | [icon]  My Integration App                         [Active]  | |\n| |         Installed 3 months ago                               | |\n| +--------------------------------------------------------------+ |\n| +--------------------------------------------------------------+ |\n| | [icon]  Analytics Plugin                          [Inactive] | |\n| |         Installed 1 week ago                                 | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n+==================================================================+\n```\n\nList of installed app cards, each containing:\n- App icon placeholder (squares icon in a rounded container)\n- App name (heading, size md)\n- Install date (relative timestamp, muted text)\n- Status badge (active=green, inactive=zinc)\n\nEach app links to a detail page showing:\n- Scopes granted (list of permission strings)\n- Webhook subscriptions table: event type, URL, status\n- Usage stats (API calls count, last call timestamp)\n\n**Empty state:** \"No apps installed\" with descriptive text.\n\n---\n\n## 16. Developers Page\n\n**Route:** `GET /admin/developers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Developers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/developers/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `tokens` | `Collection` | API tokens for the store |\n| `webhooks` | `Collection` | Webhook subscriptions |\n| `newTokenName` | `string` | Name for new token |\n| `generatedToken` | `?string` | Newly generated token (shown once) |\n| `webhookEventType` | `string` | Event type for webhook form |\n| `webhookUrl` | `string` | URL for webhook form |\n| `editingWebhook` | `?WebhookSubscription` | Webhook being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `generateToken()` | Creates a new API token |\n| `revokeToken(int $tokenId)` | Revokes a token |\n| `openWebhookModal(?WebhookSubscription $webhook)` | Opens webhook create/edit modal |\n| `saveWebhook()` | Saves webhook subscription |\n| `deleteWebhook(int $webhookId)` | Deletes a webhook |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Developers                                                        |\n+==================================================================+\n|                                                                   |\n| API TOKENS                                                        |\n| Manage personal access tokens for the Admin API.                 |\n|                                                                   |\n| Name           | Last used      | Created       | Actions        |\n|----------------|----------------|---------------|----------------|\n| My integration | 2 hours ago    | Jan 1, 2024   | [Revoke]       |\n| CI Pipeline    | Never          | Feb 10, 2024  | [Revoke]       |\n|                                                                   |\n| [Generate new token]                                              |\n|                                                                   |\n| (after generating, shown once:)                                   |\n| [!] Copy this token now. It will not be shown again.             |\n| [sk_live_abc123xyz789...]                                        |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| WEBHOOKS                                                          |\n| Manage webhook subscriptions for real-time event notifications.  |\n|                                                                   |\n| Event type           | URL                    | Status  | Act    |\n|----------------------|------------------------|---------|--------|\n| order.created        | https://ex.com/hook    | Active  | [E][D] |\n| product.updated      | https://ex.com/prod    | Failing | [E][D] |\n|                                                                   |\n| [+ Add webhook]                                                   |\n+==================================================================+\n```\n\nTwo sections separated by a Flux separator.\n\n**API Tokens section:**\n- Heading \"API tokens\" (size lg)\n- Description text\n- Token table with columns: Name, Last used (relative or \"Never\"), Created (date), Actions (Revoke button)\n- \"Generate new token\" button opens modal\n- When a token is generated: a warning callout is displayed with the token value and a note that it will only be shown once\n\n#### Generate Token Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `generate-token` |\n| Max width | md |\n| Heading | \"Generate API token\" |\n\n**Form fields:**\n- Token name input (placeholder: \"My integration\")\n- Cancel button (ghost) and \"Generate\" button (primary)\n\n**Webhooks section:**\n- Heading \"Webhooks\" (size lg)\n- Description text\n- Webhook table with columns:\n\n| Column | Content |\n|--------|---------|\n| Event type | e.g., `order.created`, `product.updated` |\n| URL | Endpoint URL |\n| Status | Flux badge: active=green, failing=red |\n| Actions | Edit and Delete buttons |\n\n- \"Add webhook\" button opens modal\n\n#### Webhook Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `webhook-form` |\n| Max width | md |\n| Heading | \"Edit webhook\" or \"Add webhook\" (dynamic) |\n\n**Form fields:**\n- Event type select with options: order.created, order.updated, order.cancelled, product.created, product.updated, product.deleted, customer.created, checkout.completed, fulfillment.created, refund.created\n- Endpoint URL input (type: url, placeholder: \"https://example.com/webhooks\")\n- Cancel button (ghost) and \"Save\" button (primary)\n\n---\n\n## 17. Analytics Page\n\n**Route:** `GET /admin/analytics`\n\n**Livewire component:** `App\\Livewire\\Admin\\Analytics\\Index`\n\n**Blade view:** `resources/views/livewire/admin/analytics/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Date range preset |\n| `customStartDate` | `?string` | Custom start date |\n| `customEndDate` | `?string` | Custom end date |\n| `channelFilter` | `string` | Sales channel filter (`all`, `storefront`, `api`) |\n| `deviceFilter` | `string` | Device filter (`all`, `desktop`, `mobile`, `tablet`) |\n| `totalSales` | `int` | Total sales for the period |\n| `ordersCount` | `int` | Orders count |\n| `averageOrderValue` | `int` | AOV |\n| `conversionRate` | `float` | Conversion rate percentage |\n| `salesChartData` | `array` | Daily sales chart data |\n| `topProducts` | `Collection` | Top products by revenue |\n| `topReferrers` | `array` | Top traffic sources |\n| `isExporting` | `bool` | Whether an export is in progress |\n| `exportUrl` | `?string` | Download URL when export is ready |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Reloads analytics data |\n| `updatedChannelFilter()` | Reloads with channel filter |\n| `updatedDeviceFilter()` | Reloads with device filter |\n| `loadAnalytics()` | Master method to reload all analytics data |\n| `exportCsv()` | Dispatches an async job to generate a CSV export |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Analytics                                                         |\n+==================================================================+\n| [Today v]  [Channel: All v]  [Device: All v]  [Export CSV]       |\n+==================================================================+\n|                                                                   |\n| KPI TILES (same as Dashboard + Conversion Rate)                  |\n| +------------+ +------------+ +------------+ +-----------+       |\n| | Total Sales| | Orders     | | AOV        | | Conv Rate |       |\n| | $45,678    | | 523        | | $87.34     | | 3.2%      |       |\n| +------------+ +------------+ +------------+ +-----------+       |\n|                                                                   |\n| SALES CHART (larger than dashboard version)                      |\n| [line/bar chart - daily revenue/orders]                          |\n|                                                                   |\n| TOP PRODUCTS (10-20 rows)                                         |\n| Rank | Product     | Units Sold | Revenue  | % of Total          |\n| 1    | Blue Shirt  | 145        | $14,500  | 31.7%               |\n| 2    | Red Hat     | 98         | $9,800   | 21.4%               |\n| ...                                                               |\n|                                                                   |\n| TOP REFERRERS                                                     |\n| Source          | Sessions | Orders | Conversion Rate             |\n| Google          | 1,200    | 45     | 3.75%                      |\n| Direct          | 800      | 32     | 4.00%                      |\n| Instagram       | 450      | 12     | 2.67%                      |\n+==================================================================+\n```\n\nSimilar to Dashboard but with additional controls and detail.\n\n**Filter bar:**\n- Date range dropdown (same as Dashboard: Today, Last 7 days, Last 30 days, Custom)\n- Channel filter: Flux select with All, Storefront, API\n- Device filter: Flux select with All, Desktop, Mobile, Tablet\n- Export button: ghost variant with download icon - calls `exportCsv()`\n\nWhen `isExporting` is true, show a loading indicator next to the export button. When `exportUrl` is set, show a download link.\n\n**KPI tiles:** Same as Dashboard (Total Sales, Orders, AOV, Conversion Rate) but with the additional filters applied.\n\n**Sales chart:** Larger chart than Dashboard, line or bar chart showing daily revenue/orders.\n\n**Top Products table:** Extended version with 10-20 rows. Columns: Rank, Product, Units Sold, Revenue, Percentage of Total.\n\n**Top Referrers table:** Columns: Traffic source, Sessions, Orders, Conversion Rate.\n\n---\n\n## 18. Search Settings\n\n**Route:** `GET /admin/search/settings`\n\n**Livewire component:** `App\\Livewire\\Admin\\Search\\Settings`\n\n**Blade view:** `resources/views/livewire/admin/search/settings.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `synonymGroups` | `array` | Array of synonym groups, each a comma-separated string |\n| `stopWords` | `string` | Comma-separated stop words |\n| `lastIndexedAt` | `?string` | Last reindex timestamp |\n| `isReindexing` | `bool` | Whether a reindex is in progress |\n| `reindexProgress` | `?int` | Progress percentage (0-100) |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Saves synonym and stop word settings |\n| `addSynonymGroup()` | Adds a new empty synonym group |\n| `removeSynonymGroup(int $index)` | Removes a synonym group |\n| `triggerReindex()` | Dispatches a reindex job |\n| `pollReindexStatus()` | Checks reindex progress (polled every 2 seconds) |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Search Settings                                                   |\n+==================================================================+\n|                                                                   |\n| SYNONYMS                                                          |\n| Define groups of words that should be treated as equivalent.     |\n|                                                                   |\n| [t-shirt, tee, tshirt              ] [trash]                    |\n| [sneakers, trainers, kicks         ] [trash]                    |\n| [+ Add synonym group]                                             |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| STOP WORDS                                                        |\n| Words that are excluded from search indexing.                    |\n| [textarea: the, a, an, is, are...           ]                   |\n| (Separate words with commas.)                                     |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| SEARCH INDEX                                                      |\n| [Reindex now]  Last indexed: Jan 15, 2024 3:45 PM               |\n|                                                                   |\n| (when reindexing:)                                                |\n| [==============          ] 65% complete                          |\n|                                                                   |\n| [Save]                                                            |\n+==================================================================+\n```\n\n**Synonyms section:**\n- Heading \"Synonyms\" (size lg)\n- Description text\n- For each synonym group: a row with text input (placeholder: \"t-shirt, tee, tshirt\") and trash button\n- \"Add synonym group\" ghost button with plus icon\n\n**Stop words section:**\n- Separator\n- Heading \"Stop words\" (size lg)\n- Description text\n- Textarea (4 rows, placeholder: \"the, a, an, is, are...\")\n- Flux description: \"Separate words with commas.\"\n\n**Reindex section:**\n- Separator\n- Heading \"Search index\" (size lg)\n- \"Reindex now\" button (disabled while reindexing, text changes to \"Reindexing...\")\n- Last indexed timestamp shown beside button (if available)\n- When reindexing: a progress bar (polled every 2 seconds) with percentage text\n\nSave button at the bottom.\n\n---\n\n## 19. Common Patterns and Conventions\n\n### 19.1 List Pages\n\nAll list pages follow a consistent pattern:\n1. Page heading with optional action button (top-right)\n2. Search input with live debounce (300ms)\n3. Filter controls (dropdowns, tabs, or both)\n4. Data table with sortable columns\n5. Pagination at the bottom\n6. Empty state when no records exist (and no filters are active)\n7. Loading states: table body shows reduced opacity during data loading\n\n### 19.2 Form Pages\n\nAll form pages follow a consistent pattern:\n1. Page heading with breadcrumbs\n2. Two-column layout (2/3 + 1/3 on large screens, stacked on mobile)\n3. Left column: primary content fields\n4. Right column: status, metadata, and secondary fields\n5. Sticky save bar at the bottom with \"Discard\" and \"Save\" buttons\n6. Inline validation errors using Flux field component with error display\n7. Loading indicator on save: save button shows loading text and becomes disabled\n8. Success toast dispatched after save\n9. Delete action behind a confirmation Flux modal\n\n### 19.3 Delete Confirmations\n\nEvery destructive action uses a Flux modal for confirmation:\n- Heading describing the action\n- Body text explaining consequences\n- Cancel button (ghost variant) that closes the modal\n- Confirm button (danger variant) that executes the destructive action\n\n### 19.4 Toast Notifications\n\nAll successful actions dispatch a success toast (e.g., \"Changes saved.\"). Error states dispatch an error toast (e.g., \"Something went wrong. Please try again.\").\n\n### 19.5 Breadcrumbs\n\nEvery page includes Flux breadcrumbs:\n- First item: \"Home\" linking to admin dashboard\n- Intermediate items: parent section (e.g., \"Products\" linking to products index)\n- Last item: current page title (no link)\n\n### 19.6 Loading States\n\nAll interactive elements show loading states:\n- **Buttons:** Disabled attribute during loading, text swaps to loading message (e.g., \"Saving...\")\n- **Tables:** Body rows show reduced opacity during data fetching\n- **Charts/tiles:** Spinner overlay targeting the specific loading method\n\n### 19.7 Card Pattern\n\nReusable card wrapper used throughout the admin:\n- White background (dark: zinc-900)\n- Rounded corners (lg)\n- Border (zinc-200, dark: zinc-700)\n- Padding (p-6)\n\n### 19.8 Responsive Design\n\n- All layouts use Tailwind responsive breakpoints: `sm:`, `md:`, `lg:`, `xl:`\n- Sidebar collapses on screens below `lg`\n- Two-column form layouts stack to single column below `lg`\n- Tables become horizontally scrollable on small screens\n- Grid columns reduce on smaller screens (e.g., 4 cols on lg, 2 on sm, 1 on mobile)\n\n### 19.9 Accessibility\n\n- All form inputs have associated Flux label elements\n- Interactive elements are keyboard-navigable\n- Focus states are visible (Tailwind's default focus ring utilities)\n- Flux modal handles focus trapping and Escape key dismissal\n- Color is never the sole indicator of state (badges include text labels)\n- Semantic HTML: headings hierarchy, landmark regions, lists for navigation\n- ARIA attributes where needed (e.g., `aria-expanded` on collapsibles, `aria-current=\"page\"` on active nav)\n\n---\n\n## 20. Flash / Toast Messages\n\nAll flash messages use the toast notification component. Messages are displayed after the relevant action completes.\n\n| Action | Message | Type |\n|--------|---------|------|\n| Product created | \"Product saved\" | success |\n| Product updated | \"Product saved\" | success |\n| Product archived | \"Product archived\" | success |\n| Variant added | \"Variant saved\" | success |\n| Collection saved | \"Collection saved\" | success |\n| Discount created | \"Discount saved\" | success |\n| Discount updated | \"Discount saved\" | success |\n| Order fulfilled | \"Fulfillment created\" | success |\n| Refund issued | \"Refund issued\" | success |\n| Customer created | \"Customer saved\" | success |\n| Customer updated | \"Customer saved\" | success |\n| Settings saved | \"Settings saved\" | success |\n| Theme published | \"Theme published\" | success |\n| Page saved | \"Page saved\" | success |\n| Navigation saved | \"Navigation saved\" | success |\n| Login failed | \"Invalid credentials\" | error |\n| Validation error | Dynamic field-specific message | error |\n\n---\n\n## 21. Livewire Component Summary\n\n| Component Class | Blade View | Route(s) |\n|----------------|-----------|---------|\n| `App\\Livewire\\Admin\\Layout\\Sidebar` | `livewire.admin.layout.sidebar` | Layout (all pages) |\n| `App\\Livewire\\Admin\\Layout\\TopBar` | `livewire.admin.layout.top-bar` | Layout (all pages) |\n| `App\\Livewire\\Admin\\Dashboard` | `livewire.admin.dashboard` | `GET /admin` |\n| `App\\Livewire\\Admin\\Products\\Index` | `livewire.admin.products.index` | `GET /admin/products` |\n| `App\\Livewire\\Admin\\Products\\Form` | `livewire.admin.products.form` | `GET /admin/products/create`, `GET /admin/products/{product}/edit` |\n| `App\\Livewire\\Admin\\Collections\\Index` | `livewire.admin.collections.index` | `GET /admin/collections` |\n| `App\\Livewire\\Admin\\Collections\\Form` | `livewire.admin.collections.form` | `GET /admin/collections/create`, `GET /admin/collections/{collection}/edit` |\n| `App\\Livewire\\Admin\\Inventory\\Index` | `livewire.admin.inventory.index` | `GET /admin/inventory` |\n| `App\\Livewire\\Admin\\Orders\\Index` | `livewire.admin.orders.index` | `GET /admin/orders` |\n| `App\\Livewire\\Admin\\Orders\\Show` | `livewire.admin.orders.show` | `GET /admin/orders/{order}` |\n| `App\\Livewire\\Admin\\Customers\\Index` | `livewire.admin.customers.index` | `GET /admin/customers` |\n| `App\\Livewire\\Admin\\Customers\\Show` | `livewire.admin.customers.show` | `GET /admin/customers/{customer}` |\n| `App\\Livewire\\Admin\\Discounts\\Index` | `livewire.admin.discounts.index` | `GET /admin/discounts` |\n| `App\\Livewire\\Admin\\Discounts\\Form` | `livewire.admin.discounts.form` | `GET /admin/discounts/create`, `GET /admin/discounts/{discount}/edit` |\n| `App\\Livewire\\Admin\\Settings\\General` | `livewire.admin.settings.general` | `GET /admin/settings` |\n| `App\\Livewire\\Admin\\Settings\\Domains` | `livewire.admin.settings.domains` | Tab within `GET /admin/settings` |\n| `App\\Livewire\\Admin\\Settings\\Shipping` | `livewire.admin.settings.shipping` | `GET /admin/settings/shipping` |\n| `App\\Livewire\\Admin\\Settings\\Taxes` | `livewire.admin.settings.taxes` | `GET /admin/settings/taxes` |\n| `App\\Livewire\\Admin\\Themes\\Index` | `livewire.admin.themes.index` | `GET /admin/themes` |\n| `App\\Livewire\\Admin\\Themes\\Editor` | `livewire.admin.themes.editor` | `GET /admin/themes/{theme}/editor` |\n| `App\\Livewire\\Admin\\Pages\\Index` | `livewire.admin.pages.index` | `GET /admin/pages` |\n| `App\\Livewire\\Admin\\Pages\\Form` | `livewire.admin.pages.form` | `GET /admin/pages/create`, `GET /admin/pages/{page}/edit` |\n| `App\\Livewire\\Admin\\Navigation\\Index` | `livewire.admin.navigation.index` | `GET /admin/navigation` |\n| `App\\Livewire\\Admin\\Apps\\Index` | `livewire.admin.apps.index` | `GET /admin/apps` |\n| `App\\Livewire\\Admin\\Developers\\Index` | `livewire.admin.developers.index` | `GET /admin/developers` |\n| `App\\Livewire\\Admin\\Analytics\\Index` | `livewire.admin.analytics.index` | `GET /admin/analytics` |\n| `App\\Livewire\\Admin\\Search\\Settings` | `livewire.admin.search.settings` | `GET /admin/search/settings` |\n","structuredPatch":[{"oldStart":995,"oldLines":9,"newStart":995,"newLines":12,"lines":[" |                    Total       $151.36        |                    |"," |                                              |                    |"," | PAYMENT DETAILS                              |                    |","-| Provider: Stripe  [Paid]                     |                    |","-| Amount: $151.36                              |                    |","-| Payment ID: pi_xxx...                        |                    |","+| Method: Credit Card  [Paid]                  |                    |","+| Amount: 151.36 EUR                           |                    |","+| Ref: mock_xxx...                             |                    |","+|                                              |                    |","+| (If bank_transfer + pending:)                |                    |","+| [Confirm Payment] button                     |                    |"," +----------------------------------------------+--------------------+"," ```"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"c6bdde74-3d2a-4b23-8842-5c24a86f0e5a"}
{"parentUuid":"54fc585b-c3b7-4b15-8353-59e592bf2c0d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011vXFyD36HYyVrcbGcJkaxN","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the Action Buttons section and add the fulfillment guard and fulfillment card updates."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":330,"cache_read_input_tokens":118464,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":330},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WXSvXBpjM3Yq61uDcM","type":"assistant","uuid":"7eab23bb-513e-43e2-b03d-57fd436ecc5e","timestamp":"2026-02-11T15:22:01.010Z"}
{"parentUuid":"7eab23bb-513e-43e2-b03d-57fd436ecc5e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011vXFyD36HYyVrcbGcJkaxN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Y1oPnFRLvghCWnMHhcDU8f","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","old_string":"#### Action Buttons\nShown below the heading:\n- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n- \"Refund\" button (ghost variant, shown when order is paid)","new_string":"#### Action Buttons\nShown below the heading:\n- \"Confirm payment\" button (primary, shown only when `payment_method = bank_transfer` AND `financial_status = pending`). On click, calls `confirmPayment()` which transitions the order to `paid`, commits inventory, and dispatches `OrderPaid`.\n- \"Create fulfillment\" button (shown when order is not fully fulfilled AND fulfillment guard passes - see below)\n- \"Refund\" button (ghost variant, shown when order is paid or partially_refunded)\n\n#### Fulfillment Guard\n\nA Flux `callout` (variant: warning) is displayed when the order's `financial_status` is NOT `paid` and NOT `partially_refunded`:\n\n> **Cannot create fulfillment.** Payment must be confirmed before items can be fulfilled. Current financial status: *{financial_status}*.\n\nWhen the guard condition is active, the \"Create fulfillment\" button is disabled (or hidden). This prevents shipping items before payment is secured."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":330,"cache_read_input_tokens":118464,"output_tokens":427,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":330,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WXSvXBpjM3Yq61uDcM","type":"assistant","uuid":"c41fdaf5-c5e0-4679-a044-1ebb0d339526","timestamp":"2026-02-11T15:22:08.297Z"}
{"parentUuid":"c41fdaf5-c5e0-4679-a044-1ebb0d339526","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Y1oPnFRLvghCWnMHhcDU8f","toolUseID":"toolu_01Y1oPnFRLvghCWnMHhcDU8f","timestamp":"2026-02-11T15:22:08.306Z","uuid":"525a84e0-db31-4b7e-bb15-c17fa6eba6a1"}
{"parentUuid":"c41fdaf5-c5e0-4679-a044-1ebb0d339526","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Y1oPnFRLvghCWnMHhcDU8f","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md has been updated successfully."}]},"uuid":"b3af0318-517c-46b3-99f6-2a1983581389","timestamp":"2026-02-11T15:22:08.305Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","oldString":"#### Action Buttons\nShown below the heading:\n- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n- \"Refund\" button (ghost variant, shown when order is paid)","newString":"#### Action Buttons\nShown below the heading:\n- \"Confirm payment\" button (primary, shown only when `payment_method = bank_transfer` AND `financial_status = pending`). On click, calls `confirmPayment()` which transitions the order to `paid`, commits inventory, and dispatches `OrderPaid`.\n- \"Create fulfillment\" button (shown when order is not fully fulfilled AND fulfillment guard passes - see below)\n- \"Refund\" button (ghost variant, shown when order is paid or partially_refunded)\n\n#### Fulfillment Guard\n\nA Flux `callout` (variant: warning) is displayed when the order's `financial_status` is NOT `paid` and NOT `partially_refunded`:\n\n> **Cannot create fulfillment.** Payment must be confirmed before items can be fulfilled. Current financial status: *{financial_status}*.\n\nWhen the guard condition is active, the \"Create fulfillment\" button is disabled (or hidden). This prevents shipping items before payment is secured.","originalFile":"# 03 - Admin Panel UI/UX Specification\n\nThis document defines every view, component, and interaction for the Shopify-clone admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.\n\n**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip\n\nAnything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.\n\n---\n\n## 1. Admin Layout Shell\n\nThe admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.\n\n### 1.1 Route Prefix\n\nAll admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.\n\n### 1.2 Sidebar Navigation\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\Sidebar`\n\n**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |\n| `currentRoute` | `string` | The current route name for active highlighting |\n\n| Action | Behavior |\n|--------|----------|\n| `toggle()` | Toggles the `collapsed` state |\n\n#### Wireframe\n\n```\n+-------------------------------+\n|  [BRAND LOGO]  Platform Name  |\n|-------------------------------|\n|  [separator]                  |\n|                               |\n|  [chart-bar]    Dashboard     |\n|                               |\n|  PRODUCTS                     |\n|  [cube]         Products      |\n|  [rect-stack]   Collections   |\n|  [archive-box]  Inventory     |\n|                               |\n|  [separator]                  |\n|  ORDERS                       |\n|  [shopping-bag] Orders        |\n|                               |\n|  [separator]                  |\n|  CUSTOMERS                    |\n|  [users]        Customers     |\n|                               |\n|  [separator]                  |\n|  DISCOUNTS                    |\n|  [tag]          Discounts     |\n|                               |\n|  [separator]                  |\n|  CONTENT                      |\n|  [doc-text]     Pages         |\n|  [bars-3]       Navigation    |\n|  [paint-brush]  Themes        |\n|                               |\n|  [separator]                  |\n|  [chart-pie]    Analytics     |\n|  [cog-6-tooth]  Settings      |\n|  [squares-2x2]  Apps          |\n|  [code-bracket] Developers    |\n+-------------------------------+\n```\n\n#### Navigation Structure\n\n```\nDashboard                        -> admin.dashboard\nProducts (group label)\n  Products                       -> admin.products.index\n  Collections                    -> admin.collections.index\n  Inventory                      -> admin.inventory.index\nOrders\n  Orders                         -> admin.orders.index\nCustomers\n  Customers                      -> admin.customers.index\nDiscounts\n  Discounts                      -> admin.discounts.index\nContent (group label)\n  Pages                          -> admin.pages.index\n  Navigation                     -> admin.navigation.index\n  Themes                         -> admin.themes.index\nAnalytics                        -> admin.analytics.index\nSettings                         -> admin.settings.index\nApps                             -> admin.apps.index\nDevelopers                       -> admin.developers.index\n```\n\nEach nav item displays an icon (via Flux icon component) and a text label. The active item receives a highlighted background and bold text. All navigation links use Livewire SPA-style page transitions. Groups are visually separated with Flux separator components.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Platform logo and name | `brand` |\n| Navigation icons | `icon` |\n| Group dividers | `separator` |\n\n#### Responsive Behavior\n\n- **Desktop (lg and above):** Sidebar is always visible, fixed left, 256px wide. Main content has a left margin of 256px.\n- **Mobile (below lg):** Sidebar is hidden off-screen. A hamburger button (icon: bars-3) appears in the top bar. When user clicks it, the sidebar slides in as an overlay with a semi-transparent backdrop. Clicking the backdrop or a nav item closes it.\n\n### 1.3 Top Bar\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\TopBar`\n\n**Blade view:** `resources/views/livewire/admin/layout/top-bar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `currentStoreName` | `string` | Name of the active store |\n| `stores` | `Collection` | List of stores the user has access to |\n| `unreadNotificationCount` | `int` | Count of unread notifications |\n\n| Action | Behavior |\n|--------|----------|\n| `switchStore(string $storeId)` | Switches active store in session, redirects to dashboard |\n\n#### Wireframe\n\n```\n+--------------------------------------------------------------------+\n| [hamburger*]  [Store Name v]                  [bell(3)]  [avatar v] |\n+--------------------------------------------------------------------+\n  * hamburger visible on mobile only\n\n  Store Name dropdown:\n    - Store A\n    - Store B\n    - Store C\n\n  Avatar dropdown:\n    - Settings (links to /admin/settings)\n    - [separator]\n    - Log out (POST /logout with CSRF)\n```\n\n#### Interaction Flows\n\n- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.\n- Notification bell shows an unread count badge (positioned absolutely over the icon) when `unreadNotificationCount > 0`.\n- Profile dropdown is triggered by a Flux profile component showing the user's avatar and name.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Store selector | `dropdown` |\n| Hamburger (mobile) | `button` (icon: bars-3) |\n| Notification bell | `button` (icon: bell, variant: ghost) |\n| Notification count | `badge` |\n| User profile trigger | `profile` |\n| Profile menu | `dropdown` |\n| Menu divider | `separator` |\n\n### 1.4 Main Content Area\n\nThe main content area sits to the right of the sidebar and below the top bar. Every page renders within this area.\n\n**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always \"Home\" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > \"Blue T-Shirt\".\n\n**Layout wrapper:** The content area has consistent padding (larger on desktop, smaller on mobile) and a max-width container centered horizontally.\n\n### 1.5 Toast Notifications\n\nA global toast notification system lives in the layout shell. It uses Alpine.js to listen for Livewire dispatched events.\n\n| Property | Value |\n|----------|-------|\n| Event name | `toast` |\n| Payload | `{ type: 'success' / 'error' / 'info', message: string }` |\n| Position | Top-right corner |\n| Auto-dismiss | 5 seconds |\n| Stacking | Supports multiple toasts |\n| Styling | Rounded card, shadow, colored left border (green=success, red=error, blue=info) |\n\nComponents dispatch toasts after successful actions (e.g., \"Product saved successfully.\") or on errors (\"Something went wrong. Please try again.\").\n\n### 1.6 Dark Mode\n\nThe admin supports dark mode via Tailwind's `dark:` variant. The mode follows the user's system preference by default. A toggle can be added in the profile dropdown later. All components and custom markup must include dark mode variants.\n\nDark mode preference is persisted in `localStorage` under the key `theme` with values `light` or `dark`. On page load, the stored preference is applied before first paint to avoid flash of incorrect theme. If no preference is stored, the system preference (`prefers-color-scheme`) is used as default.\n\n---\n\n## 2. Dashboard\n\n**Route:** `GET /admin`\n\n**Livewire component:** `App\\Livewire\\Admin\\Dashboard`\n\n**Blade view:** `resources/views/livewire/admin/dashboard.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Active date range preset: `today`, `last_7_days`, `last_30_days`, `custom` |\n| `customStartDate` | `?string` | Custom range start (Y-m-d) |\n| `customEndDate` | `?string` | Custom range end (Y-m-d) |\n| `totalSales` | `int` | Total sales amount in minor units |\n| `ordersCount` | `int` | Number of orders in range |\n| `averageOrderValue` | `int` | Average order value in minor units |\n| `visitorsCount` | `int` | Unique visitors in range |\n| `salesChange` | `float` | Percentage change vs previous period |\n| `ordersChange` | `float` | Percentage change vs previous period |\n| `aovChange` | `float` | Percentage change vs previous period |\n| `visitorsChange` | `float` | Percentage change vs previous period |\n| `ordersChartData` | `array` | Array of `{date, count}` for last 30 days |\n| `topProducts` | `array` | Top 5 products: `{title, units_sold, revenue}` |\n| `funnelData` | `array` | Funnel steps: `{visits, add_to_cart, checkout_started, checkout_completed}` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Re-fetches all KPI data for the selected range |\n| `loadKpis()` | Queries aggregated order/analytics data |\n| `loadChart()` | Queries daily order counts for chart |\n| `loadTopProducts()` | Queries top 5 products by revenue |\n| `loadFunnel()` | Queries conversion funnel data |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `formattedTotalSales` | Formatted currency string |\n| `formattedAov` | Formatted currency string |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Dashboard                           [Today v] (date range picker) |\n+------------------------------------------------------------------+\n|                                                                    |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|  | Total Sales |  | Orders      |  | Avg Order   |  | Visitors |  |\n|  | $12,345     |  | 156         |  | $79.13      |  | 2,340    |  |\n|  | [+12.3%] ^  |  | [+8.1%] ^  |  | [-2.1%] v  |  | [+5%] ^  |  |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|                                                                    |\n|  +--------------------------------------------------------------+  |\n|  | Orders over time                                              |  |\n|  |  [line/bar chart - daily order counts for selected range]     |  |\n|  +--------------------------------------------------------------+  |\n|                                                                    |\n|  +------------------------------+  +----------------------------+  |\n|  | Top products                 |  | Conversion funnel          |  |\n|  | Product Title | Sold | Rev   |  | Visits       [========] N |  |\n|  | Blue Shirt    | 45   | $900  |  | Add to Cart  [======]   N |  |\n|  | Red Hat       | 32   | $640  |  | Checkout     [====]     N |  |\n|  | Sneakers      | 28   | $2800 |  | Completed    [==]       N |  |\n|  | ...           |      |       |  |                            |  |\n|  +------------------------------+  +----------------------------+  |\n+--------------------------------------------------------------------+\n```\n\n### Layout Description\n\n**Page heading:** \"Dashboard\" displayed as a Flux heading (size xl).\n\n**Date range filter:** Positioned top-right. A Flux dropdown triggered by a button showing the current range label and a calendar icon. Options: Today, Last 7 days, Last 30 days, Custom range (reveals two date input fields when selected).\n\n### KPI Tiles Row\n\nA responsive 4-column grid (1 column on mobile, 2 on sm, 4 on lg).\n\nEach tile is a card containing:\n- A muted-color text label (e.g., \"Total Sales\")\n- A large heading with the formatted number value\n- A row with a Flux badge showing percentage change (green if positive, red if negative) and a directional arrow icon\n\nEach tile shows a loading state (reduced opacity) while data refreshes.\n\n### Orders Chart\n\nA card below the KPI tiles containing:\n- A Flux heading (size lg): \"Orders over time\"\n- A Chart.js canvas (line or bar chart) showing daily order counts\n- Chart.js initialized via Alpine.js; data passed from Livewire\n- A spinner overlay shown during chart data loading\n\n### Top Products Table\n\nA card containing:\n- A Flux heading (size lg): \"Top products\"\n- A plain table with columns: Product Title, Units Sold, Revenue\n- Maximum 5 rows\n- Empty state text: \"No sales data for this period.\"\n\n### Conversion Funnel\n\nA card containing:\n- A Flux heading (size lg): \"Conversion funnel\"\n- Horizontal bar visualization using Tailwind utility widths\n- Each step: label on the left, proportional-width bar on the right, count displayed\n- Steps: Visits, Add to Cart, Checkout Started, Checkout Completed\n- Bars use decreasing widths and progressively darker colors\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` (size xl) |\n| Section titles | `heading` (size lg) |\n| Date range selector | `dropdown`, `button`, `icon` |\n| Date inputs (custom range) | `input` (type date) |\n| KPI labels | `text` |\n| KPI values | `heading` (size xl) |\n| Trend badges | `badge` |\n| Trend arrows | `icon` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | KPI tiles show reduced opacity; chart shows spinner overlay |\n| Empty | \"No sales data for this period.\" in top products; funnel bars at zero width |\n| Populated | All tiles, chart, table, and funnel render with data |\n| Error | Toast notification with error message |\n\n---\n\n## 3. Products List\n\n**Route:** `GET /admin/products`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Index`\n\n**Blade view:** `resources/views/livewire/admin/products/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query, live-debounced at 300ms |\n| `statusFilter` | `string` | Status filter: `all`, `draft`, `active`, `archived` |\n| `typeFilter` | `string` | Product type filter |\n| `selectedIds` | `array<int>` | Array of selected product IDs for bulk actions |\n| `selectAll` | `bool` | Whether all visible rows are selected |\n| `sortField` | `string` | Column to sort by (default: `updated_at`) |\n| `sortDirection` | `string` | `asc` or `desc` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `updatedTypeFilter()` | Resets pagination |\n| `bulkArchive()` | Archives all selected products |\n| `bulkDelete()` | Soft-deletes selected products (with confirmation) |\n| `bulkSetActive()` | Sets selected products to active status |\n| `sortBy(string $field)` | Toggles sort direction or changes sort field |\n| `toggleSelectAll()` | Selects or deselects all visible product IDs |\n| `confirmBulkDelete()` | Opens delete confirmation modal |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |\n| `productTypes` | Distinct product types for the type filter dropdown |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Products                                    [+ Add product]       |\n+------------------------------------------------------------------+\n| [search icon] Search products...   [Status: All v] [Type: All v] |\n+------------------------------------------------------------------+\n| (shown when items selected)                                       |\n| \"3 products selected\"  [Set Active]  [Archive]  [Delete]         |\n+------------------------------------------------------------------+\n| [x] | IMG | Title ^     | Status  | Inventory | Type | Vendor | Updated ^  |\n|-----|-----|-------------|---------|-----------|------|--------|-----------|\n| [ ] | img | Blue Shirt  | Active  | 45        | Tops | Nike   | 2h ago    |\n| [ ] | img | Red Hat     | Draft   | 12        | Hats | Adidas | 1d ago    |\n| [ ] | img | Sneakers    | Active  | 0         | Shoes| Nike   | 3d ago    |\n| ...                                                              |\n+------------------------------------------------------------------+\n| < 1 2 3 ... 10 >  (pagination)                                  |\n+------------------------------------------------------------------+\n```\n\n### Table Column Definitions\n\n| Column | Sortable | Content |\n|--------|----------|---------|\n| Select | No | Checkbox for bulk selection |\n| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |\n| Title | Yes | Product title as a link to the edit page (uses Livewire navigate) |\n| Status | No | Flux badge: draft=zinc/gray, active=green, archived=red |\n| Inventory | Yes | Sum of `inventory_items.quantity_on_hand` across all variants |\n| Type | No | Product type text or \"-\" |\n| Vendor | No | Vendor name text or \"-\" |\n| Updated | Yes (default) | Relative timestamp (e.g., \"2h ago\") |\n\n### Bulk Action Bar\n\nConditionally shown when `count($selectedIds) > 0`. Contains:\n- Text showing \"X products selected\"\n- \"Set Active\" button (ghost variant)\n- \"Archive\" button (ghost variant)\n- \"Delete\" button (danger variant) - opens confirmation modal\n\n### Empty State\n\nWhen no products exist (not filtered, genuinely empty):\n- Centered container with a large muted placeholder icon (cube)\n- Heading: \"Add your first product\"\n- Text: \"Start building your catalog by adding products.\"\n- Primary button: \"Add product\" linking to the create page\n\n### Delete Confirmation Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-bulk-delete` |\n| Max width | md |\n| Heading | \"Delete products?\" |\n| Body text | \"This will archive X product(s). Products with orders cannot be permanently deleted.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `bulkDelete` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Add product button | `button` (variant: primary) |\n| Search field | `input` (icon: magnifying-glass) |\n| Status filter | `select` |\n| Type filter | `select` |\n| Bulk action buttons | `button` (ghost/danger variants) |\n| Selected count text | `text` |\n| Row checkbox | `checkbox` |\n| Status badges | `badge` |\n| Cell text | `text` |\n| Delete confirmation | `modal` |\n| Pagination | Laravel default Tailwind pagination |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | Table body shows reduced opacity |\n| Empty (no products) | Empty state with illustration and CTA |\n| Empty (filtered) | Table with \"No products match your filters\" message |\n| Populated | Table with data rows and pagination |\n\n---\n\n## 4. Product Create/Edit\n\n**Route:** `GET /admin/products/create` and `GET /admin/products/{product}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Form`\n\n**Blade view:** `resources/views/livewire/admin/products/form.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `product` | `?Product` | The product being edited, null for create |\n| `title` | `string` | Product title |\n| `descriptionHtml` | `string` | Product description (HTML) |\n| `status` | `string` | `draft`, `active`, `archived` |\n| `vendor` | `string` | Vendor name |\n| `productType` | `string` | Product type string |\n| `tags` | `string` | Comma-separated tags |\n| `handle` | `string` | URL handle/slug |\n| `publishedAt` | `?string` | Publish date (Y-m-d H:i) |\n| `collectionIds` | `array<int>` | Selected collection IDs |\n| `options` | `array` | Options array: `[{name: string, values: string[]}]` |\n| `variants` | `array` | Variants array: `[{sku, price, compareAtPrice, quantity, requiresShipping, optionValues}]` |\n| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |\n| `newMedia` | `array` | Temporary uploaded files |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(?Product $product)` | Loads product data into properties or sets defaults for create |\n| `save()` | Validates and saves/creates the product, variants, options, and media |\n| `addOption()` | Adds a new empty option `{name: '', values: []}` |\n| `removeOption(int $index)` | Removes an option and regenerates variants |\n| `addOptionValue(int $optionIndex)` | Adds a value to an option |\n| `removeOptionValue(int $optionIndex, int $valueIndex)` | Removes a value |\n| `generateVariants()` | Generates variant combinations from current options |\n| `uploadMedia()` | Handles file upload via Livewire temporary upload |\n| `removeMedia(int $mediaId)` | Removes a media item |\n| `reorderMedia(array $order)` | Updates media positions |\n| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |\n| `deleteProduct()` | Archives the product (with confirmation modal) |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `availableCollections` | All collections for the current store |\n| `isEditing` | `bool` - whether editing or creating |\n\n### Validation Rules\n\n| Field | Rules |\n|-------|-------|\n| `title` | Required, string, max 255 |\n| `descriptionHtml` | Nullable, string, max 65535 |\n| `status` | Required, one of: draft, active, archived |\n| `vendor` | Nullable, string, max 255 |\n| `productType` | Nullable, string, max 255 |\n| `tags` | Nullable, string |\n| `handle` | Required, string, max 255, unique (excluding current product) |\n| `variants.*.sku` | Nullable, string, max 255 |\n| `variants.*.price` | Required, integer, min 0 |\n| `variants.*.compareAtPrice` | Nullable, integer, min 0 |\n| `variants.*.quantity` | Required, integer, min 0 |\n\n### Two-Column Layout Wireframe\n\n```\n+------------------------------------------+-------------------+\n|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|\n+------------------------------------------+-------------------+\n|                                          |                   |\n|  TITLE                                   |  STATUS CARD      |\n|  +------------------------------------+  |  Status: [Draft v]|\n|  | [Short Sleeve T-Shirt           ]  |  |                   |\n|  +------------------------------------+  |  PUBLISHING CARD  |\n|                                          |  Published at:    |\n|  DESCRIPTION                             |  [datetime input]  |\n|  +------------------------------------+  |                   |\n|  | Describe your product...           |  |  ORGANIZATION     |\n|  |                                    |  |  Vendor:          |\n|  |                                    |  |  [Nike          ] |\n|  |                                    |  |  Product type:    |\n|  +------------------------------------+  |  [T-Shirts      ] |\n|                                          |  Tags:            |\n|  MEDIA                                   |  [summer, cotton ] |\n|  +------------------------------------+  |  (comma-separated)|\n|  | +--+ +--+ +--+ +--+               |  |                   |\n|  | |  | |  | |  | |  |  (thumbnails) |  |  COLLECTIONS      |\n|  | +--+ +--+ +--+ +--+               |  |  [x] Summer Sale  |\n|  |                                    |  |  [ ] Winter       |\n|  | [--- Drag & drop or click ---]    |  |  [x] New Arrivals |\n|  +------------------------------------+  |                   |\n|                                          |                   |\n|  VARIANTS                                |                   |\n|  Option name: [Size    ]                 |                   |\n|  Values:      [S, M, L, XL         ]    |                   |\n|  [trash]                                 |                   |\n|                                          |                   |\n|  [+ Add another option]                  |                   |\n|                                          |                   |\n|  Variant | SKU    | Price | Compare | Qty | Ship |          |\n|  S / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|  M / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|  L / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|                                          |                   |\n|  SEO (collapsible)                       |                   |\n|  > Search engine listing                 |                   |\n|    URL handle: [short-sleeve-t-shirt]    |                   |\n+------------------------------------------+-------------------+\n\n+==================================================================+\n| STICKY SAVE BAR (fixed bottom)                                    |\n|                                      [Discard]  [Save]           |\n+==================================================================+\n```\n\n### Left Column Sections\n\n#### Title Input\n- Flux field with label \"Title\"\n- Flux input, placeholder \"Short Sleeve T-Shirt\"\n- Flux error display for validation\n\n#### Description\n- Flux field with label \"Description\"\n- Flux textarea, 8 rows, placeholder \"Describe your product...\"\n- Future enhancement: WYSIWYG toolbar via Alpine.js; baseline is plain textarea\n\n#### Media Section\nA card with heading \"Media\" (size md).\n\n**Upload area:** A dashed-border drop zone with upload icon and text \"Drag and drop images or click to upload\". A hidden file input (multiple, accept images) triggered by clicking the zone.\n\n**Upload progress:** A progress bar shown during file upload.\n\n**Media grid:** A 4-column grid (responsive: 2 on small, 3 on sm, 4 on lg) of thumbnails:\n- Each item: square aspect ratio, object-cover, rounded\n- On hover overlay shows: delete button (X icon, top-right), alt text edit button (pencil icon, bottom-left)\n- Drag-to-reorder supported via Livewire sortable or Alpine.js Sortable\n\n#### Variants Section\nA card with heading \"Variants\" (size md).\n\n**Options builder:** For each option, a row containing:\n- \"Option name\" input field (placeholder: \"Size\")\n- \"Values\" input field (placeholder: \"S, M, L, XL\" - comma-separated)\n- Trash button to remove the option\n\nBelow options: \"Add another option\" ghost button with plus icon.\n\nWhen options change, `generateVariants()` is called automatically via lifecycle hook.\n\n**Variants table:** Shown when variants exist. Scrollable table with inline-editable fields:\n\n| Column | Input Type | Description |\n|--------|-----------|-------------|\n| Variant | Read-only text | Combination name, e.g. \"S / Red\" |\n| SKU | Text input (small) | Stock keeping unit |\n| Price | Number input (small) | Price in major units |\n| Compare at price | Number input (small) | Original price for sale display |\n| Quantity | Number input (small) | On-hand inventory |\n| Requires shipping | Checkbox | Whether variant needs shipping |\n\n#### SEO Section\nA collapsible section (collapsed by default, expands with animation):\n- Toggle button: chevron icon + \"Search engine listing\" text\n- When expanded: URL handle input field with placeholder \"short-sleeve-t-shirt\" and validation error display\n\n### Right Column Sections\n\n#### Status Card\n- Flux field with label \"Status\"\n- Flux select with options: Draft, Active, Archived\n\n#### Publishing Card\n- Flux field with label \"Published at\"\n- Datetime-local input\n\n#### Product Organization Card\n- Vendor: Flux input, placeholder \"Nike\"\n- Product type: Flux input, placeholder \"T-Shirts\"\n- Tags: Flux input, placeholder \"summer, cotton, sale\" with description \"Separate tags with commas\"\n\n#### Collections Card\n- Flux field with label \"Collections\"\n- For each available collection: a row with checkbox and collection title text\n- Checkbox values bound to `collectionIds`\n\n### Sticky Save Bar\n\nFixed at the bottom of the viewport. Appears when changes are detected.\n- Left-aligned to account for sidebar width on desktop\n- \"Discard\" button (ghost variant) - links back to products index\n- \"Save\" button (primary variant) - calls `save()`, shows \"Saving...\" text during loading, disabled while loading\n\n### Delete Confirmation Modal (Edit Only)\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-delete-product` |\n| Max width | md |\n| Heading | \"Delete this product?\" |\n| Body text | \"This product will be archived. Products with existing orders cannot be permanently removed.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `deleteProduct` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Section titles | `heading` (size md) |\n| All form labels | `field`, label via Flux |\n| Text inputs | `input` |\n| Textarea | `textarea` |\n| Status/type selects | `select` |\n| Checkboxes | `checkbox` |\n| Validation errors | `field` error display |\n| Tags description | `field` description |\n| Save/discard buttons | `button` |\n| Delete confirmation | `modal` |\n| Section dividers | `separator` |\n| Variant icons | `icon` |\n| Status text | `text` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading (save) | Save button disabled, text changes to \"Saving...\" |\n| Loading (upload) | Progress bar shown in media section |\n| Create mode | Empty form, no delete button, heading says \"Add product\" |\n| Edit mode | Pre-filled form, delete button visible, heading shows product title |\n| Validation error | Inline error messages below each invalid field |\n\n---\n\n## 5. Collections List and Edit\n\n### 5.1 Collections List\n\n**Route:** `GET /admin/collections`\n\n**Livewire component:** `App\\Livewire\\Admin\\Collections\\Index`\n\n**Blade view:** `resources/views/livewire/admin/collections/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query |\n| `statusFilter` | `string` | `all`, `active`, `archived` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `deleteCollection(int $id)` | Deletes a collection after confirmation |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `collections` | Paginated, filtered, searched. Includes `withCount('products')`. |\n\n#### Layout\n\nSame pattern as Products list:\n- Heading \"Collections\" with \"Add collection\" primary button\n- Search input\n- Status filter dropdown\n\n#### Table Columns\n\n| Column | Content |\n|--------|---------|\n| Title | Link to edit page |\n| Products count | Number of products in collection |\n| Status | Flux badge |\n| Updated date | Relative timestamp |\n\n**Empty state:** \"Create your first collection\" with CTA button.\n\n### 5.2 Collection Edit/Create\n\n**Route:** `GET /admin/collections/create` and `GET /admin/collections/{collection}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Collections\\Form`\n\n**Blade view:** `resources/views/livewire/admin/collections/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collection` | `?Collection` | The collection being edited |\n| `title` | `string` | Collection title |\n| `handle` | `string` | URL slug |\n| `descriptionHtml` | `string` | Collection description |\n| `status` | `string` | `active` or `archived` |\n| `productSearch` | `string` | Search input for product assignment |\n| `assignedProductIds` | `array<int>` | Ordered list of product IDs in this collection |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the collection with product assignments |\n| `addProduct(int $productId)` | Adds a product to the collection |\n| `removeProduct(int $productId)` | Removes a product from the collection |\n| `reorderProducts(array $order)` | Updates product positions |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `searchResults` | Products matching `productSearch` that are not already assigned |\n| `assignedProducts` | Ordered collection of assigned Product models |\n\n#### Layout Wireframe\n\n```\n+------------------------------------------+-------------------+\n|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|\n+------------------------------------------+-------------------+\n|  Title: [Summer Collection         ]     | Status: [Active v] |\n|  Handle: [summer-collection        ]     |                   |\n|  Description:                            |                   |\n|  [textarea...                      ]     |                   |\n|                                          |                   |\n|  PRODUCTS                                |                   |\n|  Search: [Search products...       ]     |                   |\n|  +----------------------------------+   |                   |\n|  | Blue Shirt           [Add]       |   |                   |\n|  | Red Hat              [Add]       |   |                   |\n|  +----------------------------------+   |                   |\n|                                          |                   |\n|  Assigned products:                      |                   |\n|  [drag] [img] Sneakers         [remove]  |                   |\n|  [drag] [img] Blue Shirt       [remove]  |                   |\n+------------------------------------------+-------------------+\n\n+==================================================================+\n| STICKY SAVE BAR                          [Discard]  [Save]       |\n+==================================================================+\n```\n\n**Left column:**\n- Title input\n- Handle input\n- Description textarea\n- Products assignment section:\n  - Search input with live debounce (300ms)\n  - Search results dropdown (conditionally shown): list of matching products with \"Add\" button\n  - Assigned products list: each row shows product title, thumbnail, and a remove button. Rows are sortable via drag-to-reorder.\n\n**Right column:**\n- Status: Flux select with Active / Archived\n- Sticky save bar (same pattern as product form)\n\n---\n\n## 6. Inventory Management\n\n**Route:** `GET /admin/inventory`\n\n**Livewire component:** `App\\Livewire\\Admin\\Inventory\\Index`\n\n**Blade view:** `resources/views/livewire/admin/inventory/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by product title or SKU |\n| `stockFilter` | `string` | `all`, `in_stock`, `low_stock`, `out_of_stock` |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `inventoryItems` | Paginated inventory items with variant and product eager loaded, filtered by search and stock level |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Inventory                                                         |\n+------------------------------------------------------------------+\n| [search] Search by product or SKU   [Stock: All v]               |\n+------------------------------------------------------------------+\n| Product | Variant | SKU     | On Hand | Reserved | Policy | Act  |\n|---------|---------|---------|---------|----------|--------|------|\n| Shirt   | S/Blue  | SH-001  | [45]    | 3        | deny   | ...  |\n| Shirt   | M/Blue  | SH-002  | [0]     | 0        | deny   | ...  |\n| Hat     | Red     | HT-001  | [12]    | 1        | cont   | ...  |\n+------------------------------------------------------------------+\n```\n\n### Layout\n\n- Heading \"Inventory\"\n- Search input and stock filter dropdown\n- Table columns: Product, Variant, SKU, On Hand (inline-editable number input), Reserved, Policy (shown as Flux badge: \"deny\" or \"continue\"), Actions\n\n---\n\n## 7. Orders List\n\n**Route:** `GET /admin/orders`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Index`\n\n**Blade view:** `resources/views/livewire/admin/orders/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by order number or customer email |\n| `statusFilter` | `string` | `all`, `pending`, `paid`, `fulfilled`, `cancelled`, `refunded` |\n| `sortField` | `string` | Sort column (default: `placed_at`) |\n| `sortDirection` | `string` | `asc` or `desc` (default: `desc`) |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `sortBy(string $field)` | Updates sort |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `orders` | Paginated query with search, status filter, sorting. Eager loads `customer`. |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Orders                                                            |\n+------------------------------------------------------------------+\n| [search] Search by order # or email...                           |\n+------------------------------------------------------------------+\n| [All] [Pending] [Paid] [Fulfilled] [Cancelled] [Refunded]       |\n+------------------------------------------------------------------+\n| Order #  | Date           | Customer  | Payment   | Fulfill | Total  |\n|----------|----------------|-----------|-----------|---------|--------|\n| #1001    | Jan 15, 2:30 PM| John Doe  | Paid      | Unful.  | $131.36|\n| #1000    | Jan 14, 9:15 AM| Guest     | Pending   | Unful.  | $45.00 |\n+------------------------------------------------------------------+\n| < 1 2 3 >                                                        |\n+------------------------------------------------------------------+\n```\n\n### Filter Tabs\n\nA horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.\n\n### Table Column Definitions\n\n| Column | Content |\n|--------|---------|\n| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n| Customer | Customer name or \"Guest\" |\n| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n| Total | Formatted currency amount |\n\nPagination below the table.\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Search field | `input` |\n| Status badges | `badge` |\n| Cell text | `text` |\n\n---\n\n## 8. Order Detail\n\n**Route:** `GET /admin/orders/{order}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n\n**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `confirmPayment()` | Confirms bank transfer payment received (transitions financial_status to `paid`, commits inventory, dispatches `OrderPaid`) |\n| `createFulfillment()` | Creates a fulfillment record for selected lines (enforces fulfillment guard) |\n| `markAsShipped(int $fulfillmentId)` | Transitions fulfillment to `shipped`, sets `shipped_at` |\n| `markAsDelivered(int $fulfillmentId)` | Transitions fulfillment to `delivered`, sets `delivered_at` |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |\n\n### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n| Jan 15, 2024 2:30 PM                        | John Doe           |\n|                                              | john@example.com   |\n| [Create fulfillment]  [Refund]               | > View customer    |\n|                                              |--------------------|\n| TIMELINE                                     | SHIPPING ADDRESS   |\n| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n| |                                            | Springfield, IL    |\n| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n| |                                            | United States      |\n| o  (more events...)                          |--------------------|\n|                                              | BILLING ADDRESS    |\n| ORDER LINES                                  | 123 Main St        |\n| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n|                                              |                    |\n|                    Subtotal     $140.00       |                    |\n|                    Discount     -$12.00       |                    |\n|                    Shipping      $5.00        |                    |\n|                    Tax          $18.36        |                    |\n|                    Total       $151.36        |                    |\n|                                              |                    |\n| PAYMENT DETAILS                              |                    |\n| Method: Credit Card  [Paid]                  |                    |\n| Amount: 151.36 EUR                           |                    |\n| Ref: mock_xxx...                             |                    |\n|                                              |                    |\n| (If bank_transfer + pending:)                |                    |\n| [Confirm Payment] button                     |                    |\n+----------------------------------------------+--------------------+\n```\n\n### Left Column Sections\n\n#### Order Heading\n- Order number as a large heading\n- Financial status badge (color mapped: paid=green, default=zinc)\n- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n- Placed-at date as muted text\n\n#### Action Buttons\nShown below the heading:\n- \"Create fulfillment\" button (shown when order is not fully fulfilled)\n- \"Refund\" button (ghost variant, shown when order is paid)\n\n#### Timeline\nA vertical timeline with left border line and dot markers:\n- Each event: title text (bold) and timestamp text (small, muted)\n- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n\n#### Order Lines Table\n\n| Column | Content |\n|--------|---------|\n| Image | 40x40 product thumbnail |\n| Product | Title and SKU |\n| Quantity | Unit count |\n| Unit Price | Formatted amount |\n| Total | Line total formatted |\n\n#### Order Summary\nA right-aligned summary block below the lines table:\n- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n- Each row is a flex container with label left and amount right\n\n#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)\n\n### Right Column Sections\n\n#### Customer Card\n- Heading \"Customer\" (size md)\n- Separator\n- Customer name (or \"Guest\")\n- Customer email (muted text)\n- \"View customer\" link (shown only if customer exists, links to customer detail page)\n\n#### Shipping Address Card\n- Heading \"Shipping address\" (size md)\n- Separator\n- Full address rendered from shipping_address_json: line1, city/state/zip, country\n\n#### Billing Address Card\nSame pattern as shipping address, using billing_address_json.\n\n### Fulfillment Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-fulfillment` |\n| Max width | lg |\n| Heading | \"Create fulfillment\" |\n\n**Content:**\n- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n- Separator\n- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n- Tracking number input field\n- Tracking URL input field (type: url)\n- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n\n### Refund Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-refund` |\n| Max width | lg |\n| Heading | \"Refund order\" |\n\n**Content:**\n- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n- Separator\n- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n- Cancel button (ghost) and \"Create refund\" button (danger)\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Order number heading | `heading` |\n| Status badges | `badge` |\n| Action buttons | `button` |\n| Section headings | `heading` (size md) |\n| Section dividers | `separator` |\n| Text content | `text` |\n| Both modals | `modal` |\n| Modal form fields | `field`, `input`, `textarea` |\n| Line checkboxes | `checkbox` |\n| Modal buttons | `button` |\n| Icons | `icon` |\n| Callout messages | `callout` |\n\n---\n\n## 9. Customers List and Detail\n\n### 9.1 Customers List\n\n**Route:** `GET /admin/customers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by name or email |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `customers` | Paginated, searched customers with `withCount('orders')` and `withSum('orders', 'total_amount')` |\n\n#### Layout\n\n- Heading \"Customers\"\n- Search input\n- Table columns: Name, Email, Orders count, Total spent (formatted), Created date\n- Each row links to customer detail\n\n### 9.2 Customer Detail\n\n**Route:** `GET /admin/customers/{customer}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Show`\n\n**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `customer` | `Customer` | The customer model |\n| `editingAddress` | `?CustomerAddress` | Address currently being edited |\n| `addressLabel` | `string` | Label for address form |\n| `addressJson` | `array` | Address fields being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Customer $customer)` | Loads customer with orders and addresses |\n| `openAddressForm(?CustomerAddress $address)` | Opens address modal for create/edit |\n| `saveAddress()` | Saves the address |\n| `deleteAddress(int $addressId)` | Deletes an address |\n| `setDefaultAddress(int $addressId)` | Sets address as default |\n\n#### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| CUSTOMER INFO                                | ADDRESSES          |\n| Name: Jane Smith                             | Home (default)     |\n| Email: jane@example.com                      | 123 Main St        |\n| Created: Jan 1, 2024                         | Springfield, IL    |\n| Marketing: [Opted In]                        | [Edit] [Delete]    |\n|                                              |                    |\n| ORDER HISTORY                                | Office             |\n| Order # | Date      | Status | Total         | 456 Corp Ave       |\n| #1005   | Jan 20    | Paid   | $89.00        | Chicago, IL        |\n| #1001   | Jan 15    | Paid   | $131.36       | [Edit] [Delete]    |\n| < 1 2 >                                     |                    |\n|                                              | [+ Add address]    |\n+----------------------------------------------+--------------------+\n```\n\n**Left column:**\n- Customer info card: Name, email, created date, marketing opt-in status (Flux badge)\n- Order history table: columns Order #, Date, Status, Total. Each order links to order detail. Paginated.\n\n**Right column:**\n- Addresses card: List of addresses, each showing label, full address text, default badge (if `is_default`), Edit and Delete buttons\n- \"Add address\" button opens address modal\n\n#### Address Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `address-form` |\n| Max width | md |\n| Heading | \"Edit address\" or \"Add address\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"Home, Office...\")\n- Address line 1 input\n- Address line 2 input\n- City and State/Province inputs (side by side, 2-column grid)\n- ZIP/Postal code and Country select (side by side, 2-column grid)\n- Cancel button (ghost) and \"Save\" button (primary)\n\n---\n\n## 10. Discounts List and Form\n\n### 10.1 Discounts List\n\n**Route:** `GET /admin/discounts`\n\n**Livewire component:** `App\\Livewire\\Admin\\Discounts\\Index`\n\n**Blade view:** `resources/views/livewire/admin/discounts/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by discount code |\n| `statusFilter` | `string` | `all`, `active`, `expired`, `scheduled` |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `discounts` | Paginated, filtered discounts |\n\n#### Layout\n\n- Heading \"Discounts\" with \"Create discount\" primary button\n- Search input and status filter\n\n#### Table Columns\n\n| Column | Content |\n|--------|---------|\n| Code | Discount code or \"Automatic\" label |\n| Type | Flux badge: \"Code\" or \"Automatic\" |\n| Value | e.g., \"10%\" or \"$5.00\" or \"Free shipping\" |\n| Usage | \"3 / 100\" (used / limit) or \"3 / unlimited\" |\n| Status | Flux badge: active=green, expired=red, scheduled=yellow |\n| Dates | Start and end dates |\n\n### 10.2 Discount Form\n\n**Route:** `GET /admin/discounts/create` and `GET /admin/discounts/{discount}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Discounts\\Form`\n\n**Blade view:** `resources/views/livewire/admin/discounts/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `discount` | `?Discount` | The discount being edited |\n| `type` | `string` | `code` or `automatic` |\n| `code` | `string` | Discount code (if type is code) |\n| `valueType` | `string` | `percent`, `fixed`, `free_shipping` |\n| `valueAmount` | `?int` | Discount value (percentage or fixed amount) |\n| `minimumPurchaseAmount` | `?int` | Minimum cart value for eligibility |\n| `specificProductIds` | `array<int>` | Specific products the discount applies to |\n| `specificCollectionIds` | `array<int>` | Specific collections the discount applies to |\n| `usageLimit` | `?int` | Maximum total uses |\n| `onePerCustomer` | `bool` | Limit to one use per customer |\n| `startsAt` | `string` | Start datetime |\n| `endsAt` | `?string` | End datetime (optional) |\n| `isActive` | `bool` | Whether the discount is active |\n| `productSearch` | `string` | Search for products to add |\n| `collectionSearch` | `string` | Search for collections to add |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the discount |\n| `generateCode()` | Auto-generates a random discount code |\n| `addProduct(int $productId)` | Adds product to specific products list |\n| `removeProduct(int $productId)` | Removes product |\n| `addCollection(int $collectionId)` | Adds collection to specific collections |\n| `removeCollection(int $collectionId)` | Removes collection |\n\n#### Layout Wireframe (Single Column)\n\n```\n+==================================================================+\n| TYPE CARD                                                         |\n|  ( ) Discount code - Customers enter a code at checkout          |\n|  (o) Automatic discount - Applied automatically at checkout      |\n+==================================================================+\n| CODE CARD (shown when type = \"code\")                             |\n|  Discount code: [SUMMER20             ]  [Generate]              |\n+==================================================================+\n| VALUE CARD                                                        |\n|  ( ) Percentage                                                   |\n|  (o) Fixed amount                                                 |\n|  ( ) Free shipping                                                |\n|                                                                   |\n|  Amount: [5.00                        ] (hidden for free shipping)|\n+==================================================================+\n| CONDITIONS CARD                                                   |\n|  Minimum purchase amount: [0.00       ]                          |\n|  (Leave empty for no minimum)                                     |\n|                                                                   |\n|  Specific products:                                               |\n|  [Search products...          ]                                   |\n|  +---results dropdown---+                                         |\n|  Selected: Blue Shirt [x]  Red Hat [x]                           |\n|                                                                   |\n|  Specific collections: (same pattern as products)                 |\n+==================================================================+\n| USAGE LIMITS CARD                                                 |\n|  Total usage limit: [100              ] (or leave blank)         |\n|  [x] Limit to one use per customer                               |\n+==================================================================+\n| ACTIVE DATES CARD                                                 |\n|  Start date: [2024-01-15 00:00       ]                           |\n|  End date:   [2024-02-15 00:00       ] (or leave blank)          |\n+==================================================================+\n| STATUS                                                            |\n|  [switch: ON]  Active                                             |\n+==================================================================+\n| STICKY SAVE BAR                          [Discard]  [Save]       |\n+==================================================================+\n```\n\n#### Section Details\n\n**Type section:** Two radio buttons - \"Discount code\" (description: \"Customers enter a code at checkout\") and \"Automatic discount\" (description: \"Applied automatically at checkout\"). Selecting type is live-bound.\n\n**Code input:** Shown only when type = \"code\". Text input with placeholder \"SUMMER20\" and a \"Generate\" ghost button that auto-generates a random code.\n\n**Value section:** Three radio buttons for value type: Percentage, Fixed amount, Free shipping. When not free shipping, a number input appears for the amount (label changes based on type: \"Percentage\" or \"Amount\").\n\n**Conditions section:**\n- Minimum purchase amount: number input with description \"Leave empty for no minimum\"\n- Specific products: search input with live debounce. Results appear in a scrollable dropdown. Selected products shown as a list with remove (X) buttons.\n- Specific collections: same pattern as products.\n\n**Usage limits section:**\n- Total usage limit number input (placeholder: \"Unlimited\")\n- \"Limit to one use per customer\" checkbox\n\n**Active dates section:**\n- Start date: datetime-local input\n- End date: datetime-local input with description \"Leave empty for no end date\"\n\n**Status:** Toggle switch with text label \"Active\" or \"Disabled\".\n\nSticky save bar at the bottom (same pattern as product form).\n\n---\n\n## 11. Settings Pages\n\n### 11.1 General Settings\n\n**Route:** `GET /admin/settings`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\General`\n\n**Blade view:** `resources/views/livewire/admin/settings/general.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `storeName` | `string` | Store display name |\n| `storeHandle` | `string` | Store handle (read-only or with warning) |\n| `defaultCurrency` | `string` | Currency code (e.g., \"EUR\") |\n| `defaultLocale` | `string` | Locale code (e.g., \"en\") |\n| `timezone` | `string` | Timezone string |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves store settings |\n\n#### Layout\n\nTwo-column layout per section: left side has section label and description, right side has form fields.\n\n**Store details section:**\n- Left: Heading \"Store details\" + description \"Basic information about your store.\"\n- Right: Store name input, Store handle input (disabled, with description: \"The store handle cannot be changed after creation.\")\n\nSeparator between sections.\n\n**Defaults section:**\n- Left: Heading \"Defaults\" + description \"Currency, language, and timezone settings.\"\n- Right:\n  - Default currency: Select (EUR/USD/GBP and more)\n  - Default locale: Select (English/German/French and more)\n  - Timezone: Select (populated from PHP timezone list)\n\nSave button at the bottom (primary variant, right-aligned).\n\n### 11.2 Domains Settings\n\nDomains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Domains`\n\n**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `domains` | `Collection` | Store domains |\n| `newHostname` | `string` | Hostname for new domain |\n| `newType` | `string` | Type for new domain: `storefront`, `admin`, `api` |\n\n| Action | Behavior |\n|--------|----------|\n| `addDomain()` | Creates a new domain record |\n| `removeDomain(int $domainId)` | Removes a domain |\n| `setPrimary(int $domainId)` | Sets domain as primary |\n\n#### Layout\n\n- Heading \"Domains\"\n- Table of existing domains:\n\n| Column | Content |\n|--------|---------|\n| Hostname | Domain hostname |\n| Type | Flux badge: storefront/admin/api |\n| Primary | Flux badge (green) \"Primary\" if `is_primary` |\n| TLS | Flux badge showing TLS mode |\n| Actions | \"Set Primary\" button, \"Delete\" button |\n\n- \"Add domain\" button opens a modal\n\n#### Add Domain Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `add-domain` |\n| Max width | md |\n| Heading | \"Add domain\" |\n\n**Form fields:**\n- Hostname input (placeholder: \"shop.example.com\")\n- Type select: Storefront, Admin, API\n- Cancel button (ghost) and \"Add domain\" button (primary)\n\n### 11.3 Shipping Settings\n\n**Route:** `GET /admin/settings/shipping`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Shipping`\n\n**Blade view:** `resources/views/livewire/admin/settings/shipping.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `zones` | `Collection` | Shipping zones with rates |\n| `editingZone` | `?ShippingZone` | Zone being edited |\n| `zoneName` | `string` | Name for zone form |\n| `zoneCountries` | `array` | Selected countries for zone |\n| `editingRate` | `?ShippingRate` | Rate being edited |\n| `rateName` | `string` | Rate name |\n| `rateType` | `string` | `flat`, `weight`, `price`, `carrier` |\n| `rateConfig` | `array` | Rate configuration (depends on type) |\n| `rateActive` | `bool` | Whether rate is active |\n| `testAddress` | `array` | Address fields for test tool |\n| `testResult` | `?array` | Result of test address lookup |\n\n| Action | Behavior |\n|--------|----------|\n| `openZoneModal(?ShippingZone $zone)` | Opens zone create/edit modal |\n| `saveZone()` | Saves shipping zone |\n| `deleteZone(int $zoneId)` | Deletes a shipping zone |\n| `openRateModal(int $zoneId, ?ShippingRate $rate)` | Opens rate create/edit modal |\n| `saveRate()` | Saves shipping rate |\n| `deleteRate(int $rateId)` | Deletes a rate |\n| `testShippingAddress()` | Tests which zone/rate matches a given address |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| Shipping                                         [+ Add zone]    |\n+==================================================================+\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | ZONE: Domestic                            [Edit] [Delete]    | |\n| | Countries: US, CA                                             | |\n| |                                                               | |\n| | Name      | Type    | Config           | Active | Actions    | |\n| |-----------|---------|------------------|--------|------------|  |\n| | Standard  | flat    | $5.00            | [on]   | [Ed] [Del]| |\n| | Express   | flat    | $15.00           | [on]   | [Ed] [Del]| |\n| | [+ Add rate]                                                  | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | ZONE: Europe                              [Edit] [Delete]    | |\n| | Countries: DE, FR, GB, ...                                    | |\n| | (rates table...)                                              | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n| TEST SHIPPING ADDRESS                                             |\n| Country: [v]  State: [    ]  City: [    ]  ZIP: [    ]           |\n| [Test]                                                            |\n| Result: Matched zone: Domestic                                    |\n|         Standard - $5.00 / Express - $15.00                      |\n+==================================================================+\n```\n\nEach zone is displayed as a card with:\n- Zone name heading and Edit/Delete buttons in the header\n- Countries list as muted text\n- Rates table inside the zone card with columns: Name, Type (as badge), Config summary, Active (toggle switch), Actions (Edit/Delete buttons)\n- \"Add rate\" button at the bottom of each zone card\n\n\"Add zone\" button at the top of the page.\n\n#### Zone Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `zone-form` |\n| Max width | md |\n| Heading | \"Edit shipping zone\" or \"Add shipping zone\" (dynamic) |\n\n**Form fields:**\n- Zone name input (placeholder: \"Domestic, Europe, International...\")\n- Countries: scrollable checklist of all available countries with checkboxes\n- Cancel button (ghost) and \"Save zone\" button (primary)\n\n#### Rate Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `rate-form` |\n| Max width | md |\n| Heading | \"Edit shipping rate\" or \"Add shipping rate\" (dynamic) |\n\n**Form fields:**\n- Rate name input (placeholder: \"Standard, Express...\")\n- Rate type select: Flat rate, Weight-based, Price-based, Carrier-calculated\n- Dynamic config fields based on rate type:\n  - **Flat:** Price input\n  - **Weight-based:** Min weight (g) input, Max weight (g) input, Price input\n  - **Price-based:** Min order amount input, Max order amount input, Price input\n  - **Carrier:** Info callout: \"Carrier-calculated rates require a carrier integration to be configured.\"\n- Active toggle switch with \"Active\" text label\n- Cancel button (ghost) and \"Save rate\" button (primary)\n\n#### Test Shipping Address Tool\n\nA card section at the bottom of the page:\n- Heading \"Test shipping address\"\n- Description: \"Enter an address to see which shipping zone and rates match.\"\n- 2-column grid of fields: Country (select), State/Region (input), City (input), ZIP/Postal code (input)\n- \"Test\" button\n- Result area (shown after test):\n  - If matched: \"Matched zone: [zone name]\" and list of available rates with prices\n  - If no match: Warning callout \"No shipping zone matches this address.\"\n\n### 11.4 Tax Settings\n\n**Route:** `GET /admin/settings/taxes`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Taxes`\n\n**Blade view:** `resources/views/livewire/admin/settings/taxes.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `mode` | `string` | `manual` or `provider` |\n| `pricesIncludeTax` | `bool` | Whether prices include tax |\n| `provider` | `string` | Provider name (e.g., `stripe_tax`) |\n| `providerApiKey` | `string` | API key for tax provider |\n| `manualRates` | `array` | Array of `{zone_name, rate_percentage}` |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Saves tax settings |\n| `addManualRate()` | Adds a new empty rate row |\n| `removeManualRate(int $index)` | Removes a manual rate |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| TAXES                                                             |\n+==================================================================+\n| MODE SELECTION                                                    |\n|  ( ) Manual tax rates - Define tax rates per zone manually       |\n|  (o) Tax provider - Use an automated tax calculation service     |\n+==================================================================+\n| MANUAL RATES TABLE (shown when mode = \"manual\")                  |\n| Zone name       | Rate (%)    |                                   |\n|-----------------|-------------|                                   |\n| [EU           ] | [19.00    ] | [trash]                          |\n| [US-CA        ] | [7.25     ] | [trash]                          |\n| [+ Add rate]                                                      |\n+==================================================================+\n| PROVIDER CONFIG (shown when mode = \"provider\")                   |\n|  Provider: [Stripe Tax v]                                        |\n|  API key:  [*************]                                       |\n+==================================================================+\n| [separator]                                                       |\n| TAX-INCLUSIVE TOGGLE                                              |\n|  [switch: ON]  Prices include tax                                |\n|  Description: When enabled, the listed price includes tax.       |\n|  Tax is calculated backwards from the price.                      |\n+==================================================================+\n| [Save]                                                            |\n+==================================================================+\n```\n\n**Mode selection:** Two radio buttons - \"Manual tax rates\" (description: \"Define tax rates per zone manually\") and \"Tax provider\" (description: \"Use an automated tax calculation service\"). Live-bound.\n\n**Manual rates table:** Shown when mode = \"manual\". Inline-editable table with columns: Zone name (text input), Rate % (number input with step 0.01). Each row has a trash button. \"Add rate\" ghost button with plus icon.\n\n**Provider config:** Shown when mode = \"provider\". Provider select (Stripe Tax / None) and API key password input.\n\n**Tax-inclusive toggle:** Toggle switch with \"Prices include tax\" label and description text explaining the behavior.\n\nSave button at the bottom.\n\n---\n\n## 12. Themes Management\n\n### 12.1 Themes List\n\n**Route:** `GET /admin/themes`\n\n**Livewire component:** `App\\Livewire\\Admin\\Themes\\Index`\n\n**Blade view:** `resources/views/livewire/admin/themes/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `themes` | `Collection` | All themes for the store |\n\n| Action | Behavior |\n|--------|----------|\n| `publishTheme(int $themeId)` | Publishes a theme (sets as active) |\n| `duplicateTheme(int $themeId)` | Creates a copy of the theme |\n| `deleteTheme(int $themeId)` | Deletes a theme |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| Themes                                                            |\n+==================================================================+\n|                                                                   |\n| +------------------+ +------------------+ +------------------+   |\n| | [preview image]  | | [preview image]  | | [preview image]  |   |\n| |                  | |                  | |                  |   |\n| | My Theme   v1.0  | | Dark Theme v1.2  | | Minimal    v2.0  |   |\n| | [Published]      | | [Draft]          | | [Draft]          |   |\n| |                  | |                  | |                  |   |\n| | [Customize] [...] | | [Customize] [...] | | [Customize] [...] |   |\n| +------------------+ +------------------+ +------------------+   |\n|                                                                   |\n+==================================================================+\n```\n\n**Theme grid:** Responsive 3-column grid (1 on mobile, 2 on md, 3 on lg).\n\nEach theme card contains:\n- **Thumbnail area:** Aspect-video container with theme preview image or placeholder\n- **Info section:**\n  - Theme name (heading, size md) and version text\n  - Status badge (published=green, draft=zinc)\n- **Actions:**\n  - \"Customize\" primary button (links to theme editor)\n  - More actions dropdown (triggered by ellipsis icon button):\n    - \"Preview\" menu item\n    - \"Publish\" menu item (hidden if already published)\n    - \"Duplicate\" menu item\n    - Separator\n    - \"Delete\" menu item (styled red/danger)\n\nThe published theme card has a blue ring border highlight.\n\n### 12.2 Theme Editor\n\n**Route:** `GET /admin/themes/{theme}/editor`\n\n**Livewire component:** `App\\Livewire\\Admin\\Themes\\Editor`\n\n**Blade view:** `resources/views/livewire/admin/themes/editor.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `theme` | `Theme` | The theme being edited |\n| `sections` | `array` | List of theme sections from settings schema |\n| `selectedSection` | `?string` | Currently selected section key |\n| `sectionSettings` | `array` | Settings values for the selected section |\n| `previewUrl` | `string` | URL for the live preview iframe |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Theme $theme)` | Loads theme and its sections |\n| `selectSection(string $sectionKey)` | Changes the selected section and loads its settings |\n| `updateSetting(string $key, mixed $value)` | Updates a setting value |\n| `save()` | Saves all section settings to theme_settings |\n| `publish()` | Publishes the theme |\n| `refreshPreview()` | Reloads the preview iframe |\n\n#### Layout Wireframe (Full Width, Three-Panel)\n\n```\n+==================================================================+\n| [<- Back to themes]                     [Save]  [Save & publish] |\n+==================================================================+\n| SECTIONS   |  LIVE PREVIEW (iframe)       |  SETTINGS PANEL      |\n| (264px)    |  (flexible width)            |  (320px)             |\n|            |                              |                      |\n| Header     |  +------------------------+ |  Header Settings     |\n| Hero       |  |                        | |  [separator]         |\n| Features*  |  |  Live storefront       | |                      |\n| Products   |  |  preview rendered      | |  Logo text:          |\n| Footer     |  |  in an iframe          | |  [My Store        ]  |\n|            |  |                        | |                      |\n|            |  |                        | |  Background color:   |\n|            |  |                        | |  [color picker]      |\n|            |  |                        | |                      |\n|            |  |                        | |  Show search:        |\n|            |  |                        | |  [x] checkbox        |\n|            |  |                        | |                      |\n|            |  +------------------------+ |  Layout:             |\n|            |                              |  [Centered       v]  |\n+==================================================================+\n  * = currently selected section (highlighted)\n```\n\n**Full-width layout** (no standard admin shell padding). Three-panel layout:\n\n**Top toolbar:** A horizontal bar with:\n- \"Back to themes\" button (ghost, with left arrow icon) linking to themes index\n- \"Save\" ghost button and \"Save and publish\" primary button\n\n**Left panel (264px):** Section list\n- Heading \"Sections\"\n- Vertical list of section buttons\n- Active section gets highlighted background and bold text\n- Clicking a section calls `selectSection()`\n\n**Center panel (flexible):** Live preview\n- Background color behind the iframe\n- Full-height iframe loading the storefront preview URL\n- Iframe has rounded border and shadow\n\n**Right panel (320px):** Settings form\n- Shows settings for the selected section\n- Heading with section label\n- Separator\n- Dynamic form fields based on section field definitions:\n\n| Field Type | UI Element |\n|-----------|------------|\n| `text` | Flux input (live-debounced at 500ms) |\n| `textarea` | Flux textarea (live-debounced at 500ms, 3 rows) |\n| `color` | Native color picker input |\n| `select` | Flux select with options from field definition |\n| `checkbox` | Flux checkbox |\n\n- When no section is selected: centered text \"Select a section to edit its settings.\"\n\n---\n\n## 13. Pages Management\n\n### 13.1 Pages List\n\n**Route:** `GET /admin/pages`\n\n**Livewire component:** `App\\Livewire\\Admin\\Pages\\Index`\n\n**Blade view:** `resources/views/livewire/admin/pages/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by page title |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `pages` | Paginated pages for the current store |\n\n#### Layout\n\n- Heading \"Pages\" with \"Add page\" primary button\n- Search input\n- Table columns: Title (link to edit), Handle, Status (Flux badge), Updated date\n- Pagination\n\n### 13.2 Page Form\n\n**Route:** `GET /admin/pages/create` and `GET /admin/pages/{page}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Pages\\Form`\n\n**Blade view:** `resources/views/livewire/admin/pages/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `page` | `?Page` | Page being edited |\n| `title` | `string` | Page title |\n| `handle` | `string` | URL handle |\n| `bodyHtml` | `string` | Page body content (HTML) |\n| `status` | `string` | `draft` or `active` |\n| `publishedAt` | `?string` | Publish date |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the page |\n| `deletePage()` | Deletes the page |\n\n#### Layout (Two-Column)\n\n**Left column (2/3):**\n- Title: Flux input\n- Handle: Flux input\n- Body: Flux textarea (16 rows)\n\n**Right column (1/3):**\n- Status: Flux select with Draft/Active\n- Published at: Datetime-local input\n\nSticky save bar (same pattern as other forms).\n\n---\n\n## 14. Navigation Management\n\n**Route:** `GET /admin/navigation`\n\n**Livewire component:** `App\\Livewire\\Admin\\Navigation\\Index`\n\n**Blade view:** `resources/views/livewire/admin/navigation/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `menus` | `Collection` | Navigation menus (Main Menu, Footer Menu) |\n| `editingMenu` | `?NavigationMenu` | Menu being edited |\n| `menuItems` | `array` | Nested array of menu items for the editing menu |\n| `editingItem` | `?array` | Item currently being edited in the modal |\n| `editingItemIndex` | `?int` | Index of item being edited |\n| `itemLabel` | `string` | Item label |\n| `itemType` | `string` | `link`, `page`, `collection`, `product` |\n| `itemUrl` | `string` | URL if type is link |\n| `itemResourceId` | `?int` | Resource ID if type is page/collection/product |\n\n| Action | Behavior |\n|--------|----------|\n| `selectMenu(int $menuId)` | Loads a menu for editing |\n| `addItem()` | Opens the item form modal in create mode |\n| `editItem(int $index)` | Opens the item form modal in edit mode |\n| `saveItem()` | Saves the item (add or update) |\n| `removeItem(int $index)` | Removes an item |\n| `reorderItems(array $order)` | Updates item positions (supports nesting) |\n| `saveMenu()` | Persists all menu items |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Navigation                                                        |\n+==================================================================+\n|                                                                   |\n| MENUS                                                             |\n| +----------------------------+  +----------------------------+   |\n| | Main Menu        [Edit]    |  | Footer Menu       [Edit]   |   |\n| +----------------------------+  +----------------------------+   |\n|                                                                   |\n| MENU EDITOR (shown when a menu is selected)                      |\n| +--------------------------------------------------------------+ |\n| | Main Menu                                    [+ Add item]    | |\n| |                                                               | |\n| | [drag] Home               link: /            [edit] [trash]  | |\n| | [drag] Products           collection: All    [edit] [trash]  | |\n| | [drag] About Us           page: About        [edit] [trash]  | |\n| | [drag] Contact            link: /contact     [edit] [trash]  | |\n| |                                                               | |\n| |                                            [Save menu]       | |\n| +--------------------------------------------------------------+ |\n+==================================================================+\n```\n\n**Menu list:** Each menu displayed as a card with title and \"Edit\" button.\n\n**Menu editor (shown when a menu is selected):**\n- Menu title as heading\n- \"Add item\" button (ghost, plus icon) in the header\n- Sortable list of menu items, each row containing:\n  - Drag handle (bars icon)\n  - Item label (bold text)\n  - Item type and target (small, muted text: e.g., \"link: /about\" or \"collection: Summer Sale\")\n  - Edit button (pencil icon) and Delete button (trash icon)\n- \"Save menu\" primary button at the bottom\n\n#### Menu Item Form Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `item-form` |\n| Max width | md |\n| Heading | \"Edit menu item\" or \"Add menu item\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"About Us\")\n- Type select: Custom link, Page, Collection, Product (live-bound)\n- Conditional fields based on type:\n  - **Custom link:** URL input (placeholder: \"https://...\")\n  - **Page/Collection/Product:** Resource select dropdown populated with available resources of that type\n- Cancel button (ghost) and \"Save item\" button (primary)\n\n---\n\n## 15. Apps Page\n\n**Route:** `GET /admin/apps`\n\n**Livewire component:** `App\\Livewire\\Admin\\Apps\\Index`\n\n**Blade view:** `resources/views/livewire/admin/apps/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `installedApps` | `Collection` | Apps installed for this store |\n\n| Action | Behavior |\n|--------|----------|\n| `uninstallApp(int $appId)` | Uninstalls an app (with confirmation) |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Apps                                                              |\n+==================================================================+\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | [icon]  My Integration App                         [Active]  | |\n| |         Installed 3 months ago                               | |\n| +--------------------------------------------------------------+ |\n| +--------------------------------------------------------------+ |\n| | [icon]  Analytics Plugin                          [Inactive] | |\n| |         Installed 1 week ago                                 | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n+==================================================================+\n```\n\nList of installed app cards, each containing:\n- App icon placeholder (squares icon in a rounded container)\n- App name (heading, size md)\n- Install date (relative timestamp, muted text)\n- Status badge (active=green, inactive=zinc)\n\nEach app links to a detail page showing:\n- Scopes granted (list of permission strings)\n- Webhook subscriptions table: event type, URL, status\n- Usage stats (API calls count, last call timestamp)\n\n**Empty state:** \"No apps installed\" with descriptive text.\n\n---\n\n## 16. Developers Page\n\n**Route:** `GET /admin/developers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Developers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/developers/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `tokens` | `Collection` | API tokens for the store |\n| `webhooks` | `Collection` | Webhook subscriptions |\n| `newTokenName` | `string` | Name for new token |\n| `generatedToken` | `?string` | Newly generated token (shown once) |\n| `webhookEventType` | `string` | Event type for webhook form |\n| `webhookUrl` | `string` | URL for webhook form |\n| `editingWebhook` | `?WebhookSubscription` | Webhook being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `generateToken()` | Creates a new API token |\n| `revokeToken(int $tokenId)` | Revokes a token |\n| `openWebhookModal(?WebhookSubscription $webhook)` | Opens webhook create/edit modal |\n| `saveWebhook()` | Saves webhook subscription |\n| `deleteWebhook(int $webhookId)` | Deletes a webhook |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Developers                                                        |\n+==================================================================+\n|                                                                   |\n| API TOKENS                                                        |\n| Manage personal access tokens for the Admin API.                 |\n|                                                                   |\n| Name           | Last used      | Created       | Actions        |\n|----------------|----------------|---------------|----------------|\n| My integration | 2 hours ago    | Jan 1, 2024   | [Revoke]       |\n| CI Pipeline    | Never          | Feb 10, 2024  | [Revoke]       |\n|                                                                   |\n| [Generate new token]                                              |\n|                                                                   |\n| (after generating, shown once:)                                   |\n| [!] Copy this token now. It will not be shown again.             |\n| [sk_live_abc123xyz789...]                                        |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| WEBHOOKS                                                          |\n| Manage webhook subscriptions for real-time event notifications.  |\n|                                                                   |\n| Event type           | URL                    | Status  | Act    |\n|----------------------|------------------------|---------|--------|\n| order.created        | https://ex.com/hook    | Active  | [E][D] |\n| product.updated      | https://ex.com/prod    | Failing | [E][D] |\n|                                                                   |\n| [+ Add webhook]                                                   |\n+==================================================================+\n```\n\nTwo sections separated by a Flux separator.\n\n**API Tokens section:**\n- Heading \"API tokens\" (size lg)\n- Description text\n- Token table with columns: Name, Last used (relative or \"Never\"), Created (date), Actions (Revoke button)\n- \"Generate new token\" button opens modal\n- When a token is generated: a warning callout is displayed with the token value and a note that it will only be shown once\n\n#### Generate Token Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `generate-token` |\n| Max width | md |\n| Heading | \"Generate API token\" |\n\n**Form fields:**\n- Token name input (placeholder: \"My integration\")\n- Cancel button (ghost) and \"Generate\" button (primary)\n\n**Webhooks section:**\n- Heading \"Webhooks\" (size lg)\n- Description text\n- Webhook table with columns:\n\n| Column | Content |\n|--------|---------|\n| Event type | e.g., `order.created`, `product.updated` |\n| URL | Endpoint URL |\n| Status | Flux badge: active=green, failing=red |\n| Actions | Edit and Delete buttons |\n\n- \"Add webhook\" button opens modal\n\n#### Webhook Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `webhook-form` |\n| Max width | md |\n| Heading | \"Edit webhook\" or \"Add webhook\" (dynamic) |\n\n**Form fields:**\n- Event type select with options: order.created, order.updated, order.cancelled, product.created, product.updated, product.deleted, customer.created, checkout.completed, fulfillment.created, refund.created\n- Endpoint URL input (type: url, placeholder: \"https://example.com/webhooks\")\n- Cancel button (ghost) and \"Save\" button (primary)\n\n---\n\n## 17. Analytics Page\n\n**Route:** `GET /admin/analytics`\n\n**Livewire component:** `App\\Livewire\\Admin\\Analytics\\Index`\n\n**Blade view:** `resources/views/livewire/admin/analytics/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Date range preset |\n| `customStartDate` | `?string` | Custom start date |\n| `customEndDate` | `?string` | Custom end date |\n| `channelFilter` | `string` | Sales channel filter (`all`, `storefront`, `api`) |\n| `deviceFilter` | `string` | Device filter (`all`, `desktop`, `mobile`, `tablet`) |\n| `totalSales` | `int` | Total sales for the period |\n| `ordersCount` | `int` | Orders count |\n| `averageOrderValue` | `int` | AOV |\n| `conversionRate` | `float` | Conversion rate percentage |\n| `salesChartData` | `array` | Daily sales chart data |\n| `topProducts` | `Collection` | Top products by revenue |\n| `topReferrers` | `array` | Top traffic sources |\n| `isExporting` | `bool` | Whether an export is in progress |\n| `exportUrl` | `?string` | Download URL when export is ready |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Reloads analytics data |\n| `updatedChannelFilter()` | Reloads with channel filter |\n| `updatedDeviceFilter()` | Reloads with device filter |\n| `loadAnalytics()` | Master method to reload all analytics data |\n| `exportCsv()` | Dispatches an async job to generate a CSV export |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Analytics                                                         |\n+==================================================================+\n| [Today v]  [Channel: All v]  [Device: All v]  [Export CSV]       |\n+==================================================================+\n|                                                                   |\n| KPI TILES (same as Dashboard + Conversion Rate)                  |\n| +------------+ +------------+ +------------+ +-----------+       |\n| | Total Sales| | Orders     | | AOV        | | Conv Rate |       |\n| | $45,678    | | 523        | | $87.34     | | 3.2%      |       |\n| +------------+ +------------+ +------------+ +-----------+       |\n|                                                                   |\n| SALES CHART (larger than dashboard version)                      |\n| [line/bar chart - daily revenue/orders]                          |\n|                                                                   |\n| TOP PRODUCTS (10-20 rows)                                         |\n| Rank | Product     | Units Sold | Revenue  | % of Total          |\n| 1    | Blue Shirt  | 145        | $14,500  | 31.7%               |\n| 2    | Red Hat     | 98         | $9,800   | 21.4%               |\n| ...                                                               |\n|                                                                   |\n| TOP REFERRERS                                                     |\n| Source          | Sessions | Orders | Conversion Rate             |\n| Google          | 1,200    | 45     | 3.75%                      |\n| Direct          | 800      | 32     | 4.00%                      |\n| Instagram       | 450      | 12     | 2.67%                      |\n+==================================================================+\n```\n\nSimilar to Dashboard but with additional controls and detail.\n\n**Filter bar:**\n- Date range dropdown (same as Dashboard: Today, Last 7 days, Last 30 days, Custom)\n- Channel filter: Flux select with All, Storefront, API\n- Device filter: Flux select with All, Desktop, Mobile, Tablet\n- Export button: ghost variant with download icon - calls `exportCsv()`\n\nWhen `isExporting` is true, show a loading indicator next to the export button. When `exportUrl` is set, show a download link.\n\n**KPI tiles:** Same as Dashboard (Total Sales, Orders, AOV, Conversion Rate) but with the additional filters applied.\n\n**Sales chart:** Larger chart than Dashboard, line or bar chart showing daily revenue/orders.\n\n**Top Products table:** Extended version with 10-20 rows. Columns: Rank, Product, Units Sold, Revenue, Percentage of Total.\n\n**Top Referrers table:** Columns: Traffic source, Sessions, Orders, Conversion Rate.\n\n---\n\n## 18. Search Settings\n\n**Route:** `GET /admin/search/settings`\n\n**Livewire component:** `App\\Livewire\\Admin\\Search\\Settings`\n\n**Blade view:** `resources/views/livewire/admin/search/settings.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `synonymGroups` | `array` | Array of synonym groups, each a comma-separated string |\n| `stopWords` | `string` | Comma-separated stop words |\n| `lastIndexedAt` | `?string` | Last reindex timestamp |\n| `isReindexing` | `bool` | Whether a reindex is in progress |\n| `reindexProgress` | `?int` | Progress percentage (0-100) |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Saves synonym and stop word settings |\n| `addSynonymGroup()` | Adds a new empty synonym group |\n| `removeSynonymGroup(int $index)` | Removes a synonym group |\n| `triggerReindex()` | Dispatches a reindex job |\n| `pollReindexStatus()` | Checks reindex progress (polled every 2 seconds) |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Search Settings                                                   |\n+==================================================================+\n|                                                                   |\n| SYNONYMS                                                          |\n| Define groups of words that should be treated as equivalent.     |\n|                                                                   |\n| [t-shirt, tee, tshirt              ] [trash]                    |\n| [sneakers, trainers, kicks         ] [trash]                    |\n| [+ Add synonym group]                                             |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| STOP WORDS                                                        |\n| Words that are excluded from search indexing.                    |\n| [textarea: the, a, an, is, are...           ]                   |\n| (Separate words with commas.)                                     |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| SEARCH INDEX                                                      |\n| [Reindex now]  Last indexed: Jan 15, 2024 3:45 PM               |\n|                                                                   |\n| (when reindexing:)                                                |\n| [==============          ] 65% complete                          |\n|                                                                   |\n| [Save]                                                            |\n+==================================================================+\n```\n\n**Synonyms section:**\n- Heading \"Synonyms\" (size lg)\n- Description text\n- For each synonym group: a row with text input (placeholder: \"t-shirt, tee, tshirt\") and trash button\n- \"Add synonym group\" ghost button with plus icon\n\n**Stop words section:**\n- Separator\n- Heading \"Stop words\" (size lg)\n- Description text\n- Textarea (4 rows, placeholder: \"the, a, an, is, are...\")\n- Flux description: \"Separate words with commas.\"\n\n**Reindex section:**\n- Separator\n- Heading \"Search index\" (size lg)\n- \"Reindex now\" button (disabled while reindexing, text changes to \"Reindexing...\")\n- Last indexed timestamp shown beside button (if available)\n- When reindexing: a progress bar (polled every 2 seconds) with percentage text\n\nSave button at the bottom.\n\n---\n\n## 19. Common Patterns and Conventions\n\n### 19.1 List Pages\n\nAll list pages follow a consistent pattern:\n1. Page heading with optional action button (top-right)\n2. Search input with live debounce (300ms)\n3. Filter controls (dropdowns, tabs, or both)\n4. Data table with sortable columns\n5. Pagination at the bottom\n6. Empty state when no records exist (and no filters are active)\n7. Loading states: table body shows reduced opacity during data loading\n\n### 19.2 Form Pages\n\nAll form pages follow a consistent pattern:\n1. Page heading with breadcrumbs\n2. Two-column layout (2/3 + 1/3 on large screens, stacked on mobile)\n3. Left column: primary content fields\n4. Right column: status, metadata, and secondary fields\n5. Sticky save bar at the bottom with \"Discard\" and \"Save\" buttons\n6. Inline validation errors using Flux field component with error display\n7. Loading indicator on save: save button shows loading text and becomes disabled\n8. Success toast dispatched after save\n9. Delete action behind a confirmation Flux modal\n\n### 19.3 Delete Confirmations\n\nEvery destructive action uses a Flux modal for confirmation:\n- Heading describing the action\n- Body text explaining consequences\n- Cancel button (ghost variant) that closes the modal\n- Confirm button (danger variant) that executes the destructive action\n\n### 19.4 Toast Notifications\n\nAll successful actions dispatch a success toast (e.g., \"Changes saved.\"). Error states dispatch an error toast (e.g., \"Something went wrong. Please try again.\").\n\n### 19.5 Breadcrumbs\n\nEvery page includes Flux breadcrumbs:\n- First item: \"Home\" linking to admin dashboard\n- Intermediate items: parent section (e.g., \"Products\" linking to products index)\n- Last item: current page title (no link)\n\n### 19.6 Loading States\n\nAll interactive elements show loading states:\n- **Buttons:** Disabled attribute during loading, text swaps to loading message (e.g., \"Saving...\")\n- **Tables:** Body rows show reduced opacity during data fetching\n- **Charts/tiles:** Spinner overlay targeting the specific loading method\n\n### 19.7 Card Pattern\n\nReusable card wrapper used throughout the admin:\n- White background (dark: zinc-900)\n- Rounded corners (lg)\n- Border (zinc-200, dark: zinc-700)\n- Padding (p-6)\n\n### 19.8 Responsive Design\n\n- All layouts use Tailwind responsive breakpoints: `sm:`, `md:`, `lg:`, `xl:`\n- Sidebar collapses on screens below `lg`\n- Two-column form layouts stack to single column below `lg`\n- Tables become horizontally scrollable on small screens\n- Grid columns reduce on smaller screens (e.g., 4 cols on lg, 2 on sm, 1 on mobile)\n\n### 19.9 Accessibility\n\n- All form inputs have associated Flux label elements\n- Interactive elements are keyboard-navigable\n- Focus states are visible (Tailwind's default focus ring utilities)\n- Flux modal handles focus trapping and Escape key dismissal\n- Color is never the sole indicator of state (badges include text labels)\n- Semantic HTML: headings hierarchy, landmark regions, lists for navigation\n- ARIA attributes where needed (e.g., `aria-expanded` on collapsibles, `aria-current=\"page\"` on active nav)\n\n---\n\n## 20. Flash / Toast Messages\n\nAll flash messages use the toast notification component. Messages are displayed after the relevant action completes.\n\n| Action | Message | Type |\n|--------|---------|------|\n| Product created | \"Product saved\" | success |\n| Product updated | \"Product saved\" | success |\n| Product archived | \"Product archived\" | success |\n| Variant added | \"Variant saved\" | success |\n| Collection saved | \"Collection saved\" | success |\n| Discount created | \"Discount saved\" | success |\n| Discount updated | \"Discount saved\" | success |\n| Order fulfilled | \"Fulfillment created\" | success |\n| Refund issued | \"Refund issued\" | success |\n| Customer created | \"Customer saved\" | success |\n| Customer updated | \"Customer saved\" | success |\n| Settings saved | \"Settings saved\" | success |\n| Theme published | \"Theme published\" | success |\n| Page saved | \"Page saved\" | success |\n| Navigation saved | \"Navigation saved\" | success |\n| Login failed | \"Invalid credentials\" | error |\n| Validation error | Dynamic field-specific message | error |\n\n---\n\n## 21. Livewire Component Summary\n\n| Component Class | Blade View | Route(s) |\n|----------------|-----------|---------|\n| `App\\Livewire\\Admin\\Layout\\Sidebar` | `livewire.admin.layout.sidebar` | Layout (all pages) |\n| `App\\Livewire\\Admin\\Layout\\TopBar` | `livewire.admin.layout.top-bar` | Layout (all pages) |\n| `App\\Livewire\\Admin\\Dashboard` | `livewire.admin.dashboard` | `GET /admin` |\n| `App\\Livewire\\Admin\\Products\\Index` | `livewire.admin.products.index` | `GET /admin/products` |\n| `App\\Livewire\\Admin\\Products\\Form` | `livewire.admin.products.form` | `GET /admin/products/create`, `GET /admin/products/{product}/edit` |\n| `App\\Livewire\\Admin\\Collections\\Index` | `livewire.admin.collections.index` | `GET /admin/collections` |\n| `App\\Livewire\\Admin\\Collections\\Form` | `livewire.admin.collections.form` | `GET /admin/collections/create`, `GET /admin/collections/{collection}/edit` |\n| `App\\Livewire\\Admin\\Inventory\\Index` | `livewire.admin.inventory.index` | `GET /admin/inventory` |\n| `App\\Livewire\\Admin\\Orders\\Index` | `livewire.admin.orders.index` | `GET /admin/orders` |\n| `App\\Livewire\\Admin\\Orders\\Show` | `livewire.admin.orders.show` | `GET /admin/orders/{order}` |\n| `App\\Livewire\\Admin\\Customers\\Index` | `livewire.admin.customers.index` | `GET /admin/customers` |\n| `App\\Livewire\\Admin\\Customers\\Show` | `livewire.admin.customers.show` | `GET /admin/customers/{customer}` |\n| `App\\Livewire\\Admin\\Discounts\\Index` | `livewire.admin.discounts.index` | `GET /admin/discounts` |\n| `App\\Livewire\\Admin\\Discounts\\Form` | `livewire.admin.discounts.form` | `GET /admin/discounts/create`, `GET /admin/discounts/{discount}/edit` |\n| `App\\Livewire\\Admin\\Settings\\General` | `livewire.admin.settings.general` | `GET /admin/settings` |\n| `App\\Livewire\\Admin\\Settings\\Domains` | `livewire.admin.settings.domains` | Tab within `GET /admin/settings` |\n| `App\\Livewire\\Admin\\Settings\\Shipping` | `livewire.admin.settings.shipping` | `GET /admin/settings/shipping` |\n| `App\\Livewire\\Admin\\Settings\\Taxes` | `livewire.admin.settings.taxes` | `GET /admin/settings/taxes` |\n| `App\\Livewire\\Admin\\Themes\\Index` | `livewire.admin.themes.index` | `GET /admin/themes` |\n| `App\\Livewire\\Admin\\Themes\\Editor` | `livewire.admin.themes.editor` | `GET /admin/themes/{theme}/editor` |\n| `App\\Livewire\\Admin\\Pages\\Index` | `livewire.admin.pages.index` | `GET /admin/pages` |\n| `App\\Livewire\\Admin\\Pages\\Form` | `livewire.admin.pages.form` | `GET /admin/pages/create`, `GET /admin/pages/{page}/edit` |\n| `App\\Livewire\\Admin\\Navigation\\Index` | `livewire.admin.navigation.index` | `GET /admin/navigation` |\n| `App\\Livewire\\Admin\\Apps\\Index` | `livewire.admin.apps.index` | `GET /admin/apps` |\n| `App\\Livewire\\Admin\\Developers\\Index` | `livewire.admin.developers.index` | `GET /admin/developers` |\n| `App\\Livewire\\Admin\\Analytics\\Index` | `livewire.admin.analytics.index` | `GET /admin/analytics` |\n| `App\\Livewire\\Admin\\Search\\Settings` | `livewire.admin.search.settings` | `GET /admin/search/settings` |\n","structuredPatch":[{"oldStart":1014,"oldLines":9,"newStart":1014,"newLines":18,"lines":[" "," #### Action Buttons"," Shown below the heading:","-- \"Create fulfillment\" button (shown when order is not fully fulfilled)","-- \"Refund\" button (ghost variant, shown when order is paid)","+- \"Confirm payment\" button (primary, shown only when `payment_method = bank_transfer` AND `financial_status = pending`). On click, calls `confirmPayment()` which transitions the order to `paid`, commits inventory, and dispatches `OrderPaid`.","+- \"Create fulfillment\" button (shown when order is not fully fulfilled AND fulfillment guard passes - see below)","+- \"Refund\" button (ghost variant, shown when order is paid or partially_refunded)"," ","+#### Fulfillment Guard","+","+A Flux `callout` (variant: warning) is displayed when the order's `financial_status` is NOT `paid` and NOT `partially_refunded`:","+","+> **Cannot create fulfillment.** Payment must be confirmed before items can be fulfilled. Current financial status: *{financial_status}*.","+","+When the guard condition is active, the \"Create fulfillment\" button is disabled (or hidden). This prevents shipping items before payment is secured.","+"," #### Timeline"," A vertical timeline with left border line and dot markers:"," - Each event: title text (bold) and timestamp text (small, muted)"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"c41fdaf5-c5e0-4679-a044-1ebb0d339526"}
{"parentUuid":"b3af0318-517c-46b3-99f6-2a1983581389","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_0163CMLumQmiASiF2ozoYqLj","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the Payment Details card description and add fulfillment card actions."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":470,"cache_read_input_tokens":118794,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":470},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WYBnTt4g2U6zHxGFuf","type":"assistant","uuid":"6730018b-241a-4f82-925b-719a09c4ba5b","timestamp":"2026-02-11T15:22:11.195Z"}
{"parentUuid":"6730018b-241a-4f82-925b-719a09c4ba5b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_0163CMLumQmiASiF2ozoYqLj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0199pyudijRYh7CexnyAn9zn","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","old_string":"#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)","new_string":"#### Payment Details\nA card showing:\n- Payment method as text (Credit Card / PayPal / Bank Transfer), derived from `order.payment_method` enum\n- Status as Flux badge (pending=zinc, captured=green, failed=red, refunded=yellow)\n- Amount formatted as currency\n- Mock reference ID (provider_payment_id)\n- **\"Confirm payment\" button** (primary variant): Shown only when `payment_method = bank_transfer` AND `financial_status = pending`. Clicking calls `confirmPayment()`.\n\n#### Fulfillment Cards\n\nEach existing fulfillment is rendered as a card below the timeline. Each card shows:\n- Fulfillment status badge (pending=zinc, shipped=blue, delivered=green)\n- Tracking info (company, number, URL link)\n- Line items included in the fulfillment\n- **Action buttons on each fulfillment card:**\n  - \"Mark as shipped\" button (shown when fulfillment status is `pending`). On click, calls `markAsShipped(fulfillmentId)`. Sets `shipped_at` timestamp.\n  - \"Mark as delivered\" button (shown when fulfillment status is `shipped`). On click, calls `markAsDelivered(fulfillmentId)`. Sets `delivered_at` timestamp. When all fulfillments are delivered, the order's `fulfillment_status` transitions to `fulfilled`."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":470,"cache_read_input_tokens":118794,"output_tokens":506,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":470,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WYBnTt4g2U6zHxGFuf","type":"assistant","uuid":"b937fbce-e4c2-47bc-a161-db10c7d70111","timestamp":"2026-02-11T15:22:18.576Z"}
{"parentUuid":"b937fbce-e4c2-47bc-a161-db10c7d70111","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_0199pyudijRYh7CexnyAn9zn","toolUseID":"toolu_0199pyudijRYh7CexnyAn9zn","timestamp":"2026-02-11T15:22:18.582Z","uuid":"83b546cd-4774-4f57-a630-32abdff5ec81"}
{"parentUuid":"b937fbce-e4c2-47bc-a161-db10c7d70111","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0199pyudijRYh7CexnyAn9zn","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md has been updated successfully."}]},"uuid":"273b0558-f6da-4782-8530-4905134c6268","timestamp":"2026-02-11T15:22:18.581Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","oldString":"#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)","newString":"#### Payment Details\nA card showing:\n- Payment method as text (Credit Card / PayPal / Bank Transfer), derived from `order.payment_method` enum\n- Status as Flux badge (pending=zinc, captured=green, failed=red, refunded=yellow)\n- Amount formatted as currency\n- Mock reference ID (provider_payment_id)\n- **\"Confirm payment\" button** (primary variant): Shown only when `payment_method = bank_transfer` AND `financial_status = pending`. Clicking calls `confirmPayment()`.\n\n#### Fulfillment Cards\n\nEach existing fulfillment is rendered as a card below the timeline. Each card shows:\n- Fulfillment status badge (pending=zinc, shipped=blue, delivered=green)\n- Tracking info (company, number, URL link)\n- Line items included in the fulfillment\n- **Action buttons on each fulfillment card:**\n  - \"Mark as shipped\" button (shown when fulfillment status is `pending`). On click, calls `markAsShipped(fulfillmentId)`. Sets `shipped_at` timestamp.\n  - \"Mark as delivered\" button (shown when fulfillment status is `shipped`). On click, calls `markAsDelivered(fulfillmentId)`. Sets `delivered_at` timestamp. When all fulfillments are delivered, the order's `fulfillment_status` transitions to `fulfilled`.","originalFile":"# 03 - Admin Panel UI/UX Specification\n\nThis document defines every view, component, and interaction for the Shopify-clone admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.\n\n**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip\n\nAnything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.\n\n---\n\n## 1. Admin Layout Shell\n\nThe admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.\n\n### 1.1 Route Prefix\n\nAll admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.\n\n### 1.2 Sidebar Navigation\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\Sidebar`\n\n**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |\n| `currentRoute` | `string` | The current route name for active highlighting |\n\n| Action | Behavior |\n|--------|----------|\n| `toggle()` | Toggles the `collapsed` state |\n\n#### Wireframe\n\n```\n+-------------------------------+\n|  [BRAND LOGO]  Platform Name  |\n|-------------------------------|\n|  [separator]                  |\n|                               |\n|  [chart-bar]    Dashboard     |\n|                               |\n|  PRODUCTS                     |\n|  [cube]         Products      |\n|  [rect-stack]   Collections   |\n|  [archive-box]  Inventory     |\n|                               |\n|  [separator]                  |\n|  ORDERS                       |\n|  [shopping-bag] Orders        |\n|                               |\n|  [separator]                  |\n|  CUSTOMERS                    |\n|  [users]        Customers     |\n|                               |\n|  [separator]                  |\n|  DISCOUNTS                    |\n|  [tag]          Discounts     |\n|                               |\n|  [separator]                  |\n|  CONTENT                      |\n|  [doc-text]     Pages         |\n|  [bars-3]       Navigation    |\n|  [paint-brush]  Themes        |\n|                               |\n|  [separator]                  |\n|  [chart-pie]    Analytics     |\n|  [cog-6-tooth]  Settings      |\n|  [squares-2x2]  Apps          |\n|  [code-bracket] Developers    |\n+-------------------------------+\n```\n\n#### Navigation Structure\n\n```\nDashboard                        -> admin.dashboard\nProducts (group label)\n  Products                       -> admin.products.index\n  Collections                    -> admin.collections.index\n  Inventory                      -> admin.inventory.index\nOrders\n  Orders                         -> admin.orders.index\nCustomers\n  Customers                      -> admin.customers.index\nDiscounts\n  Discounts                      -> admin.discounts.index\nContent (group label)\n  Pages                          -> admin.pages.index\n  Navigation                     -> admin.navigation.index\n  Themes                         -> admin.themes.index\nAnalytics                        -> admin.analytics.index\nSettings                         -> admin.settings.index\nApps                             -> admin.apps.index\nDevelopers                       -> admin.developers.index\n```\n\nEach nav item displays an icon (via Flux icon component) and a text label. The active item receives a highlighted background and bold text. All navigation links use Livewire SPA-style page transitions. Groups are visually separated with Flux separator components.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Platform logo and name | `brand` |\n| Navigation icons | `icon` |\n| Group dividers | `separator` |\n\n#### Responsive Behavior\n\n- **Desktop (lg and above):** Sidebar is always visible, fixed left, 256px wide. Main content has a left margin of 256px.\n- **Mobile (below lg):** Sidebar is hidden off-screen. A hamburger button (icon: bars-3) appears in the top bar. When user clicks it, the sidebar slides in as an overlay with a semi-transparent backdrop. Clicking the backdrop or a nav item closes it.\n\n### 1.3 Top Bar\n\n**Livewire component:** `App\\Livewire\\Admin\\Layout\\TopBar`\n\n**Blade view:** `resources/views/livewire/admin/layout/top-bar.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `currentStoreName` | `string` | Name of the active store |\n| `stores` | `Collection` | List of stores the user has access to |\n| `unreadNotificationCount` | `int` | Count of unread notifications |\n\n| Action | Behavior |\n|--------|----------|\n| `switchStore(string $storeId)` | Switches active store in session, redirects to dashboard |\n\n#### Wireframe\n\n```\n+--------------------------------------------------------------------+\n| [hamburger*]  [Store Name v]                  [bell(3)]  [avatar v] |\n+--------------------------------------------------------------------+\n  * hamburger visible on mobile only\n\n  Store Name dropdown:\n    - Store A\n    - Store B\n    - Store C\n\n  Avatar dropdown:\n    - Settings (links to /admin/settings)\n    - [separator]\n    - Log out (POST /logout with CSRF)\n```\n\n#### Interaction Flows\n\n- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.\n- Notification bell shows an unread count badge (positioned absolutely over the icon) when `unreadNotificationCount > 0`.\n- Profile dropdown is triggered by a Flux profile component showing the user's avatar and name.\n\n#### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Store selector | `dropdown` |\n| Hamburger (mobile) | `button` (icon: bars-3) |\n| Notification bell | `button` (icon: bell, variant: ghost) |\n| Notification count | `badge` |\n| User profile trigger | `profile` |\n| Profile menu | `dropdown` |\n| Menu divider | `separator` |\n\n### 1.4 Main Content Area\n\nThe main content area sits to the right of the sidebar and below the top bar. Every page renders within this area.\n\n**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always \"Home\" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > \"Blue T-Shirt\".\n\n**Layout wrapper:** The content area has consistent padding (larger on desktop, smaller on mobile) and a max-width container centered horizontally.\n\n### 1.5 Toast Notifications\n\nA global toast notification system lives in the layout shell. It uses Alpine.js to listen for Livewire dispatched events.\n\n| Property | Value |\n|----------|-------|\n| Event name | `toast` |\n| Payload | `{ type: 'success' / 'error' / 'info', message: string }` |\n| Position | Top-right corner |\n| Auto-dismiss | 5 seconds |\n| Stacking | Supports multiple toasts |\n| Styling | Rounded card, shadow, colored left border (green=success, red=error, blue=info) |\n\nComponents dispatch toasts after successful actions (e.g., \"Product saved successfully.\") or on errors (\"Something went wrong. Please try again.\").\n\n### 1.6 Dark Mode\n\nThe admin supports dark mode via Tailwind's `dark:` variant. The mode follows the user's system preference by default. A toggle can be added in the profile dropdown later. All components and custom markup must include dark mode variants.\n\nDark mode preference is persisted in `localStorage` under the key `theme` with values `light` or `dark`. On page load, the stored preference is applied before first paint to avoid flash of incorrect theme. If no preference is stored, the system preference (`prefers-color-scheme`) is used as default.\n\n---\n\n## 2. Dashboard\n\n**Route:** `GET /admin`\n\n**Livewire component:** `App\\Livewire\\Admin\\Dashboard`\n\n**Blade view:** `resources/views/livewire/admin/dashboard.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Active date range preset: `today`, `last_7_days`, `last_30_days`, `custom` |\n| `customStartDate` | `?string` | Custom range start (Y-m-d) |\n| `customEndDate` | `?string` | Custom range end (Y-m-d) |\n| `totalSales` | `int` | Total sales amount in minor units |\n| `ordersCount` | `int` | Number of orders in range |\n| `averageOrderValue` | `int` | Average order value in minor units |\n| `visitorsCount` | `int` | Unique visitors in range |\n| `salesChange` | `float` | Percentage change vs previous period |\n| `ordersChange` | `float` | Percentage change vs previous period |\n| `aovChange` | `float` | Percentage change vs previous period |\n| `visitorsChange` | `float` | Percentage change vs previous period |\n| `ordersChartData` | `array` | Array of `{date, count}` for last 30 days |\n| `topProducts` | `array` | Top 5 products: `{title, units_sold, revenue}` |\n| `funnelData` | `array` | Funnel steps: `{visits, add_to_cart, checkout_started, checkout_completed}` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Re-fetches all KPI data for the selected range |\n| `loadKpis()` | Queries aggregated order/analytics data |\n| `loadChart()` | Queries daily order counts for chart |\n| `loadTopProducts()` | Queries top 5 products by revenue |\n| `loadFunnel()` | Queries conversion funnel data |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `formattedTotalSales` | Formatted currency string |\n| `formattedAov` | Formatted currency string |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Dashboard                           [Today v] (date range picker) |\n+------------------------------------------------------------------+\n|                                                                    |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|  | Total Sales |  | Orders      |  | Avg Order   |  | Visitors |  |\n|  | $12,345     |  | 156         |  | $79.13      |  | 2,340    |  |\n|  | [+12.3%] ^  |  | [+8.1%] ^  |  | [-2.1%] v  |  | [+5%] ^  |  |\n|  +-------------+  +-------------+  +-------------+  +----------+  |\n|                                                                    |\n|  +--------------------------------------------------------------+  |\n|  | Orders over time                                              |  |\n|  |  [line/bar chart - daily order counts for selected range]     |  |\n|  +--------------------------------------------------------------+  |\n|                                                                    |\n|  +------------------------------+  +----------------------------+  |\n|  | Top products                 |  | Conversion funnel          |  |\n|  | Product Title | Sold | Rev   |  | Visits       [========] N |  |\n|  | Blue Shirt    | 45   | $900  |  | Add to Cart  [======]   N |  |\n|  | Red Hat       | 32   | $640  |  | Checkout     [====]     N |  |\n|  | Sneakers      | 28   | $2800 |  | Completed    [==]       N |  |\n|  | ...           |      |       |  |                            |  |\n|  +------------------------------+  +----------------------------+  |\n+--------------------------------------------------------------------+\n```\n\n### Layout Description\n\n**Page heading:** \"Dashboard\" displayed as a Flux heading (size xl).\n\n**Date range filter:** Positioned top-right. A Flux dropdown triggered by a button showing the current range label and a calendar icon. Options: Today, Last 7 days, Last 30 days, Custom range (reveals two date input fields when selected).\n\n### KPI Tiles Row\n\nA responsive 4-column grid (1 column on mobile, 2 on sm, 4 on lg).\n\nEach tile is a card containing:\n- A muted-color text label (e.g., \"Total Sales\")\n- A large heading with the formatted number value\n- A row with a Flux badge showing percentage change (green if positive, red if negative) and a directional arrow icon\n\nEach tile shows a loading state (reduced opacity) while data refreshes.\n\n### Orders Chart\n\nA card below the KPI tiles containing:\n- A Flux heading (size lg): \"Orders over time\"\n- A Chart.js canvas (line or bar chart) showing daily order counts\n- Chart.js initialized via Alpine.js; data passed from Livewire\n- A spinner overlay shown during chart data loading\n\n### Top Products Table\n\nA card containing:\n- A Flux heading (size lg): \"Top products\"\n- A plain table with columns: Product Title, Units Sold, Revenue\n- Maximum 5 rows\n- Empty state text: \"No sales data for this period.\"\n\n### Conversion Funnel\n\nA card containing:\n- A Flux heading (size lg): \"Conversion funnel\"\n- Horizontal bar visualization using Tailwind utility widths\n- Each step: label on the left, proportional-width bar on the right, count displayed\n- Steps: Visits, Add to Cart, Checkout Started, Checkout Completed\n- Bars use decreasing widths and progressively darker colors\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` (size xl) |\n| Section titles | `heading` (size lg) |\n| Date range selector | `dropdown`, `button`, `icon` |\n| Date inputs (custom range) | `input` (type date) |\n| KPI labels | `text` |\n| KPI values | `heading` (size xl) |\n| Trend badges | `badge` |\n| Trend arrows | `icon` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | KPI tiles show reduced opacity; chart shows spinner overlay |\n| Empty | \"No sales data for this period.\" in top products; funnel bars at zero width |\n| Populated | All tiles, chart, table, and funnel render with data |\n| Error | Toast notification with error message |\n\n---\n\n## 3. Products List\n\n**Route:** `GET /admin/products`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Index`\n\n**Blade view:** `resources/views/livewire/admin/products/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query, live-debounced at 300ms |\n| `statusFilter` | `string` | Status filter: `all`, `draft`, `active`, `archived` |\n| `typeFilter` | `string` | Product type filter |\n| `selectedIds` | `array<int>` | Array of selected product IDs for bulk actions |\n| `selectAll` | `bool` | Whether all visible rows are selected |\n| `sortField` | `string` | Column to sort by (default: `updated_at`) |\n| `sortDirection` | `string` | `asc` or `desc` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `updatedTypeFilter()` | Resets pagination |\n| `bulkArchive()` | Archives all selected products |\n| `bulkDelete()` | Soft-deletes selected products (with confirmation) |\n| `bulkSetActive()` | Sets selected products to active status |\n| `sortBy(string $field)` | Toggles sort direction or changes sort field |\n| `toggleSelectAll()` | Selects or deselects all visible product IDs |\n| `confirmBulkDelete()` | Opens delete confirmation modal |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |\n| `productTypes` | Distinct product types for the type filter dropdown |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Products                                    [+ Add product]       |\n+------------------------------------------------------------------+\n| [search icon] Search products...   [Status: All v] [Type: All v] |\n+------------------------------------------------------------------+\n| (shown when items selected)                                       |\n| \"3 products selected\"  [Set Active]  [Archive]  [Delete]         |\n+------------------------------------------------------------------+\n| [x] | IMG | Title ^     | Status  | Inventory | Type | Vendor | Updated ^  |\n|-----|-----|-------------|---------|-----------|------|--------|-----------|\n| [ ] | img | Blue Shirt  | Active  | 45        | Tops | Nike   | 2h ago    |\n| [ ] | img | Red Hat     | Draft   | 12        | Hats | Adidas | 1d ago    |\n| [ ] | img | Sneakers    | Active  | 0         | Shoes| Nike   | 3d ago    |\n| ...                                                              |\n+------------------------------------------------------------------+\n| < 1 2 3 ... 10 >  (pagination)                                  |\n+------------------------------------------------------------------+\n```\n\n### Table Column Definitions\n\n| Column | Sortable | Content |\n|--------|----------|---------|\n| Select | No | Checkbox for bulk selection |\n| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |\n| Title | Yes | Product title as a link to the edit page (uses Livewire navigate) |\n| Status | No | Flux badge: draft=zinc/gray, active=green, archived=red |\n| Inventory | Yes | Sum of `inventory_items.quantity_on_hand` across all variants |\n| Type | No | Product type text or \"-\" |\n| Vendor | No | Vendor name text or \"-\" |\n| Updated | Yes (default) | Relative timestamp (e.g., \"2h ago\") |\n\n### Bulk Action Bar\n\nConditionally shown when `count($selectedIds) > 0`. Contains:\n- Text showing \"X products selected\"\n- \"Set Active\" button (ghost variant)\n- \"Archive\" button (ghost variant)\n- \"Delete\" button (danger variant) - opens confirmation modal\n\n### Empty State\n\nWhen no products exist (not filtered, genuinely empty):\n- Centered container with a large muted placeholder icon (cube)\n- Heading: \"Add your first product\"\n- Text: \"Start building your catalog by adding products.\"\n- Primary button: \"Add product\" linking to the create page\n\n### Delete Confirmation Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-bulk-delete` |\n| Max width | md |\n| Heading | \"Delete products?\" |\n| Body text | \"This will archive X product(s). Products with orders cannot be permanently deleted.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `bulkDelete` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Add product button | `button` (variant: primary) |\n| Search field | `input` (icon: magnifying-glass) |\n| Status filter | `select` |\n| Type filter | `select` |\n| Bulk action buttons | `button` (ghost/danger variants) |\n| Selected count text | `text` |\n| Row checkbox | `checkbox` |\n| Status badges | `badge` |\n| Cell text | `text` |\n| Delete confirmation | `modal` |\n| Pagination | Laravel default Tailwind pagination |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading | Table body shows reduced opacity |\n| Empty (no products) | Empty state with illustration and CTA |\n| Empty (filtered) | Table with \"No products match your filters\" message |\n| Populated | Table with data rows and pagination |\n\n---\n\n## 4. Product Create/Edit\n\n**Route:** `GET /admin/products/create` and `GET /admin/products/{product}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Products\\Form`\n\n**Blade view:** `resources/views/livewire/admin/products/form.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `product` | `?Product` | The product being edited, null for create |\n| `title` | `string` | Product title |\n| `descriptionHtml` | `string` | Product description (HTML) |\n| `status` | `string` | `draft`, `active`, `archived` |\n| `vendor` | `string` | Vendor name |\n| `productType` | `string` | Product type string |\n| `tags` | `string` | Comma-separated tags |\n| `handle` | `string` | URL handle/slug |\n| `publishedAt` | `?string` | Publish date (Y-m-d H:i) |\n| `collectionIds` | `array<int>` | Selected collection IDs |\n| `options` | `array` | Options array: `[{name: string, values: string[]}]` |\n| `variants` | `array` | Variants array: `[{sku, price, compareAtPrice, quantity, requiresShipping, optionValues}]` |\n| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |\n| `newMedia` | `array` | Temporary uploaded files |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(?Product $product)` | Loads product data into properties or sets defaults for create |\n| `save()` | Validates and saves/creates the product, variants, options, and media |\n| `addOption()` | Adds a new empty option `{name: '', values: []}` |\n| `removeOption(int $index)` | Removes an option and regenerates variants |\n| `addOptionValue(int $optionIndex)` | Adds a value to an option |\n| `removeOptionValue(int $optionIndex, int $valueIndex)` | Removes a value |\n| `generateVariants()` | Generates variant combinations from current options |\n| `uploadMedia()` | Handles file upload via Livewire temporary upload |\n| `removeMedia(int $mediaId)` | Removes a media item |\n| `reorderMedia(array $order)` | Updates media positions |\n| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |\n| `deleteProduct()` | Archives the product (with confirmation modal) |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `availableCollections` | All collections for the current store |\n| `isEditing` | `bool` - whether editing or creating |\n\n### Validation Rules\n\n| Field | Rules |\n|-------|-------|\n| `title` | Required, string, max 255 |\n| `descriptionHtml` | Nullable, string, max 65535 |\n| `status` | Required, one of: draft, active, archived |\n| `vendor` | Nullable, string, max 255 |\n| `productType` | Nullable, string, max 255 |\n| `tags` | Nullable, string |\n| `handle` | Required, string, max 255, unique (excluding current product) |\n| `variants.*.sku` | Nullable, string, max 255 |\n| `variants.*.price` | Required, integer, min 0 |\n| `variants.*.compareAtPrice` | Nullable, integer, min 0 |\n| `variants.*.quantity` | Required, integer, min 0 |\n\n### Two-Column Layout Wireframe\n\n```\n+------------------------------------------+-------------------+\n|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|\n+------------------------------------------+-------------------+\n|                                          |                   |\n|  TITLE                                   |  STATUS CARD      |\n|  +------------------------------------+  |  Status: [Draft v]|\n|  | [Short Sleeve T-Shirt           ]  |  |                   |\n|  +------------------------------------+  |  PUBLISHING CARD  |\n|                                          |  Published at:    |\n|  DESCRIPTION                             |  [datetime input]  |\n|  +------------------------------------+  |                   |\n|  | Describe your product...           |  |  ORGANIZATION     |\n|  |                                    |  |  Vendor:          |\n|  |                                    |  |  [Nike          ] |\n|  |                                    |  |  Product type:    |\n|  +------------------------------------+  |  [T-Shirts      ] |\n|                                          |  Tags:            |\n|  MEDIA                                   |  [summer, cotton ] |\n|  +------------------------------------+  |  (comma-separated)|\n|  | +--+ +--+ +--+ +--+               |  |                   |\n|  | |  | |  | |  | |  |  (thumbnails) |  |  COLLECTIONS      |\n|  | +--+ +--+ +--+ +--+               |  |  [x] Summer Sale  |\n|  |                                    |  |  [ ] Winter       |\n|  | [--- Drag & drop or click ---]    |  |  [x] New Arrivals |\n|  +------------------------------------+  |                   |\n|                                          |                   |\n|  VARIANTS                                |                   |\n|  Option name: [Size    ]                 |                   |\n|  Values:      [S, M, L, XL         ]    |                   |\n|  [trash]                                 |                   |\n|                                          |                   |\n|  [+ Add another option]                  |                   |\n|                                          |                   |\n|  Variant | SKU    | Price | Compare | Qty | Ship |          |\n|  S / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|  M / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|  L / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |\n|                                          |                   |\n|  SEO (collapsible)                       |                   |\n|  > Search engine listing                 |                   |\n|    URL handle: [short-sleeve-t-shirt]    |                   |\n+------------------------------------------+-------------------+\n\n+==================================================================+\n| STICKY SAVE BAR (fixed bottom)                                    |\n|                                      [Discard]  [Save]           |\n+==================================================================+\n```\n\n### Left Column Sections\n\n#### Title Input\n- Flux field with label \"Title\"\n- Flux input, placeholder \"Short Sleeve T-Shirt\"\n- Flux error display for validation\n\n#### Description\n- Flux field with label \"Description\"\n- Flux textarea, 8 rows, placeholder \"Describe your product...\"\n- Future enhancement: WYSIWYG toolbar via Alpine.js; baseline is plain textarea\n\n#### Media Section\nA card with heading \"Media\" (size md).\n\n**Upload area:** A dashed-border drop zone with upload icon and text \"Drag and drop images or click to upload\". A hidden file input (multiple, accept images) triggered by clicking the zone.\n\n**Upload progress:** A progress bar shown during file upload.\n\n**Media grid:** A 4-column grid (responsive: 2 on small, 3 on sm, 4 on lg) of thumbnails:\n- Each item: square aspect ratio, object-cover, rounded\n- On hover overlay shows: delete button (X icon, top-right), alt text edit button (pencil icon, bottom-left)\n- Drag-to-reorder supported via Livewire sortable or Alpine.js Sortable\n\n#### Variants Section\nA card with heading \"Variants\" (size md).\n\n**Options builder:** For each option, a row containing:\n- \"Option name\" input field (placeholder: \"Size\")\n- \"Values\" input field (placeholder: \"S, M, L, XL\" - comma-separated)\n- Trash button to remove the option\n\nBelow options: \"Add another option\" ghost button with plus icon.\n\nWhen options change, `generateVariants()` is called automatically via lifecycle hook.\n\n**Variants table:** Shown when variants exist. Scrollable table with inline-editable fields:\n\n| Column | Input Type | Description |\n|--------|-----------|-------------|\n| Variant | Read-only text | Combination name, e.g. \"S / Red\" |\n| SKU | Text input (small) | Stock keeping unit |\n| Price | Number input (small) | Price in major units |\n| Compare at price | Number input (small) | Original price for sale display |\n| Quantity | Number input (small) | On-hand inventory |\n| Requires shipping | Checkbox | Whether variant needs shipping |\n\n#### SEO Section\nA collapsible section (collapsed by default, expands with animation):\n- Toggle button: chevron icon + \"Search engine listing\" text\n- When expanded: URL handle input field with placeholder \"short-sleeve-t-shirt\" and validation error display\n\n### Right Column Sections\n\n#### Status Card\n- Flux field with label \"Status\"\n- Flux select with options: Draft, Active, Archived\n\n#### Publishing Card\n- Flux field with label \"Published at\"\n- Datetime-local input\n\n#### Product Organization Card\n- Vendor: Flux input, placeholder \"Nike\"\n- Product type: Flux input, placeholder \"T-Shirts\"\n- Tags: Flux input, placeholder \"summer, cotton, sale\" with description \"Separate tags with commas\"\n\n#### Collections Card\n- Flux field with label \"Collections\"\n- For each available collection: a row with checkbox and collection title text\n- Checkbox values bound to `collectionIds`\n\n### Sticky Save Bar\n\nFixed at the bottom of the viewport. Appears when changes are detected.\n- Left-aligned to account for sidebar width on desktop\n- \"Discard\" button (ghost variant) - links back to products index\n- \"Save\" button (primary variant) - calls `save()`, shows \"Saving...\" text during loading, disabled while loading\n\n### Delete Confirmation Modal (Edit Only)\n\n| Property | Value |\n|----------|-------|\n| Modal name | `confirm-delete-product` |\n| Max width | md |\n| Heading | \"Delete this product?\" |\n| Body text | \"This product will be archived. Products with existing orders cannot be permanently removed.\" |\n| Cancel button | Ghost variant, closes modal |\n| Confirm button | Danger variant, calls `deleteProduct` |\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Section titles | `heading` (size md) |\n| All form labels | `field`, label via Flux |\n| Text inputs | `input` |\n| Textarea | `textarea` |\n| Status/type selects | `select` |\n| Checkboxes | `checkbox` |\n| Validation errors | `field` error display |\n| Tags description | `field` description |\n| Save/discard buttons | `button` |\n| Delete confirmation | `modal` |\n| Section dividers | `separator` |\n| Variant icons | `icon` |\n| Status text | `text` |\n\n### View States\n\n| State | Behavior |\n|-------|----------|\n| Loading (save) | Save button disabled, text changes to \"Saving...\" |\n| Loading (upload) | Progress bar shown in media section |\n| Create mode | Empty form, no delete button, heading says \"Add product\" |\n| Edit mode | Pre-filled form, delete button visible, heading shows product title |\n| Validation error | Inline error messages below each invalid field |\n\n---\n\n## 5. Collections List and Edit\n\n### 5.1 Collections List\n\n**Route:** `GET /admin/collections`\n\n**Livewire component:** `App\\Livewire\\Admin\\Collections\\Index`\n\n**Blade view:** `resources/views/livewire/admin/collections/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search query |\n| `statusFilter` | `string` | `all`, `active`, `archived` |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `deleteCollection(int $id)` | Deletes a collection after confirmation |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `collections` | Paginated, filtered, searched. Includes `withCount('products')`. |\n\n#### Layout\n\nSame pattern as Products list:\n- Heading \"Collections\" with \"Add collection\" primary button\n- Search input\n- Status filter dropdown\n\n#### Table Columns\n\n| Column | Content |\n|--------|---------|\n| Title | Link to edit page |\n| Products count | Number of products in collection |\n| Status | Flux badge |\n| Updated date | Relative timestamp |\n\n**Empty state:** \"Create your first collection\" with CTA button.\n\n### 5.2 Collection Edit/Create\n\n**Route:** `GET /admin/collections/create` and `GET /admin/collections/{collection}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Collections\\Form`\n\n**Blade view:** `resources/views/livewire/admin/collections/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `collection` | `?Collection` | The collection being edited |\n| `title` | `string` | Collection title |\n| `handle` | `string` | URL slug |\n| `descriptionHtml` | `string` | Collection description |\n| `status` | `string` | `active` or `archived` |\n| `productSearch` | `string` | Search input for product assignment |\n| `assignedProductIds` | `array<int>` | Ordered list of product IDs in this collection |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the collection with product assignments |\n| `addProduct(int $productId)` | Adds a product to the collection |\n| `removeProduct(int $productId)` | Removes a product from the collection |\n| `reorderProducts(array $order)` | Updates product positions |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `searchResults` | Products matching `productSearch` that are not already assigned |\n| `assignedProducts` | Ordered collection of assigned Product models |\n\n#### Layout Wireframe\n\n```\n+------------------------------------------+-------------------+\n|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|\n+------------------------------------------+-------------------+\n|  Title: [Summer Collection         ]     | Status: [Active v] |\n|  Handle: [summer-collection        ]     |                   |\n|  Description:                            |                   |\n|  [textarea...                      ]     |                   |\n|                                          |                   |\n|  PRODUCTS                                |                   |\n|  Search: [Search products...       ]     |                   |\n|  +----------------------------------+   |                   |\n|  | Blue Shirt           [Add]       |   |                   |\n|  | Red Hat              [Add]       |   |                   |\n|  +----------------------------------+   |                   |\n|                                          |                   |\n|  Assigned products:                      |                   |\n|  [drag] [img] Sneakers         [remove]  |                   |\n|  [drag] [img] Blue Shirt       [remove]  |                   |\n+------------------------------------------+-------------------+\n\n+==================================================================+\n| STICKY SAVE BAR                          [Discard]  [Save]       |\n+==================================================================+\n```\n\n**Left column:**\n- Title input\n- Handle input\n- Description textarea\n- Products assignment section:\n  - Search input with live debounce (300ms)\n  - Search results dropdown (conditionally shown): list of matching products with \"Add\" button\n  - Assigned products list: each row shows product title, thumbnail, and a remove button. Rows are sortable via drag-to-reorder.\n\n**Right column:**\n- Status: Flux select with Active / Archived\n- Sticky save bar (same pattern as product form)\n\n---\n\n## 6. Inventory Management\n\n**Route:** `GET /admin/inventory`\n\n**Livewire component:** `App\\Livewire\\Admin\\Inventory\\Index`\n\n**Blade view:** `resources/views/livewire/admin/inventory/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by product title or SKU |\n| `stockFilter` | `string` | `all`, `in_stock`, `low_stock`, `out_of_stock` |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `inventoryItems` | Paginated inventory items with variant and product eager loaded, filtered by search and stock level |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Inventory                                                         |\n+------------------------------------------------------------------+\n| [search] Search by product or SKU   [Stock: All v]               |\n+------------------------------------------------------------------+\n| Product | Variant | SKU     | On Hand | Reserved | Policy | Act  |\n|---------|---------|---------|---------|----------|--------|------|\n| Shirt   | S/Blue  | SH-001  | [45]    | 3        | deny   | ...  |\n| Shirt   | M/Blue  | SH-002  | [0]     | 0        | deny   | ...  |\n| Hat     | Red     | HT-001  | [12]    | 1        | cont   | ...  |\n+------------------------------------------------------------------+\n```\n\n### Layout\n\n- Heading \"Inventory\"\n- Search input and stock filter dropdown\n- Table columns: Product, Variant, SKU, On Hand (inline-editable number input), Reserved, Policy (shown as Flux badge: \"deny\" or \"continue\"), Actions\n\n---\n\n## 7. Orders List\n\n**Route:** `GET /admin/orders`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Index`\n\n**Blade view:** `resources/views/livewire/admin/orders/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by order number or customer email |\n| `statusFilter` | `string` | `all`, `pending`, `paid`, `fulfilled`, `cancelled`, `refunded` |\n| `sortField` | `string` | Sort column (default: `placed_at`) |\n| `sortDirection` | `string` | `asc` or `desc` (default: `desc`) |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedSearch()` | Resets pagination |\n| `updatedStatusFilter()` | Resets pagination |\n| `sortBy(string $field)` | Updates sort |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `orders` | Paginated query with search, status filter, sorting. Eager loads `customer`. |\n\n### Wireframe\n\n```\n+------------------------------------------------------------------+\n| Orders                                                            |\n+------------------------------------------------------------------+\n| [search] Search by order # or email...                           |\n+------------------------------------------------------------------+\n| [All] [Pending] [Paid] [Fulfilled] [Cancelled] [Refunded]       |\n+------------------------------------------------------------------+\n| Order #  | Date           | Customer  | Payment   | Fulfill | Total  |\n|----------|----------------|-----------|-----------|---------|--------|\n| #1001    | Jan 15, 2:30 PM| John Doe  | Paid      | Unful.  | $131.36|\n| #1000    | Jan 14, 9:15 AM| Guest     | Pending   | Unful.  | $45.00 |\n+------------------------------------------------------------------+\n| < 1 2 3 >                                                        |\n+------------------------------------------------------------------+\n```\n\n### Filter Tabs\n\nA horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.\n\n### Table Column Definitions\n\n| Column | Content |\n|--------|---------|\n| Order number | Link to order detail (e.g., \"#1001\"), uses Livewire navigate |\n| Date | `placed_at` formatted as \"M j, Y g:i A\" |\n| Customer | Customer name or \"Guest\" |\n| Financial status | Flux badge: pending=zinc, paid=green, refunded=yellow, cancelled=red |\n| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |\n| Total | Formatted currency amount |\n\nPagination below the table.\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Page title | `heading` |\n| Search field | `input` |\n| Status badges | `badge` |\n| Cell text | `text` |\n\n---\n\n## 8. Order Detail\n\n**Route:** `GET /admin/orders/{order}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Orders\\Show`\n\n**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |\n| `fulfillmentLines` | `array` | Lines/quantities selected for fulfillment |\n| `trackingCompany` | `string` | Tracking company for fulfillment |\n| `trackingNumber` | `string` | Tracking number |\n| `trackingUrl` | `string` | Tracking URL |\n| `refundAmount` | `?int` | Custom refund amount |\n| `refundReason` | `string` | Refund reason text |\n| `refundLines` | `array` | Lines selected for refund |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Order $order)` | Loads order with all relationships |\n| `confirmPayment()` | Confirms bank transfer payment received (transitions financial_status to `paid`, commits inventory, dispatches `OrderPaid`) |\n| `createFulfillment()` | Creates a fulfillment record for selected lines (enforces fulfillment guard) |\n| `markAsShipped(int $fulfillmentId)` | Transitions fulfillment to `shipped`, sets `shipped_at` |\n| `markAsDelivered(int $fulfillmentId)` | Transitions fulfillment to `delivered`, sets `delivered_at` |\n| `createRefund()` | Creates a refund for selected lines or custom amount |\n| `openFulfillmentModal()` | Opens the fulfillment modal |\n| `openRefundModal()` | Opens the refund modal |\n\n### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| #1001  [Paid]  [Unfulfilled]                 | CUSTOMER           |\n| Jan 15, 2024 2:30 PM                        | John Doe           |\n|                                              | john@example.com   |\n| [Create fulfillment]  [Refund]               | > View customer    |\n|                                              |--------------------|\n| TIMELINE                                     | SHIPPING ADDRESS   |\n| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |\n| |                                            | Springfield, IL    |\n| o  Payment received - Jan 15, 2:31 PM       | 62701              |\n| |                                            | United States      |\n| o  (more events...)                          |--------------------|\n|                                              | BILLING ADDRESS    |\n| ORDER LINES                                  | 123 Main St        |\n| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |\n| img | Blue Shirt   | 2   | $60.00 | $120.00 | 62701              |\n| img | Red Hat      | 1   | $20.00 | $20.00  | United States      |\n|                                              |                    |\n|                    Subtotal     $140.00       |                    |\n|                    Discount     -$12.00       |                    |\n|                    Shipping      $5.00        |                    |\n|                    Tax          $18.36        |                    |\n|                    Total       $151.36        |                    |\n|                                              |                    |\n| PAYMENT DETAILS                              |                    |\n| Method: Credit Card  [Paid]                  |                    |\n| Amount: 151.36 EUR                           |                    |\n| Ref: mock_xxx...                             |                    |\n|                                              |                    |\n| (If bank_transfer + pending:)                |                    |\n| [Confirm Payment] button                     |                    |\n+----------------------------------------------+--------------------+\n```\n\n### Left Column Sections\n\n#### Order Heading\n- Order number as a large heading\n- Financial status badge (color mapped: paid=green, default=zinc)\n- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)\n- Placed-at date as muted text\n\n#### Action Buttons\nShown below the heading:\n- \"Confirm payment\" button (primary, shown only when `payment_method = bank_transfer` AND `financial_status = pending`). On click, calls `confirmPayment()` which transitions the order to `paid`, commits inventory, and dispatches `OrderPaid`.\n- \"Create fulfillment\" button (shown when order is not fully fulfilled AND fulfillment guard passes - see below)\n- \"Refund\" button (ghost variant, shown when order is paid or partially_refunded)\n\n#### Fulfillment Guard\n\nA Flux `callout` (variant: warning) is displayed when the order's `financial_status` is NOT `paid` and NOT `partially_refunded`:\n\n> **Cannot create fulfillment.** Payment must be confirmed before items can be fulfilled. Current financial status: *{financial_status}*.\n\nWhen the guard condition is active, the \"Create fulfillment\" button is disabled (or hidden). This prevents shipping items before payment is secured.\n\n#### Timeline\nA vertical timeline with left border line and dot markers:\n- Each event: title text (bold) and timestamp text (small, muted)\n- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)\n\n#### Order Lines Table\n\n| Column | Content |\n|--------|---------|\n| Image | 40x40 product thumbnail |\n| Product | Title and SKU |\n| Quantity | Unit count |\n| Unit Price | Formatted amount |\n| Total | Line total formatted |\n\n#### Order Summary\nA right-aligned summary block below the lines table:\n- Subtotal, Discount (negative), Shipping, Tax, Total (bold)\n- Each row is a flex container with label left and amount right\n\n#### Payment Details\nA card showing:\n- Provider (Stripe/PayPal) as text\n- Status as Flux badge\n- Amount\n- Provider payment ID (linked if applicable)\n\n### Right Column Sections\n\n#### Customer Card\n- Heading \"Customer\" (size md)\n- Separator\n- Customer name (or \"Guest\")\n- Customer email (muted text)\n- \"View customer\" link (shown only if customer exists, links to customer detail page)\n\n#### Shipping Address Card\n- Heading \"Shipping address\" (size md)\n- Separator\n- Full address rendered from shipping_address_json: line1, city/state/zip, country\n\n#### Billing Address Card\nSame pattern as shipping address, using billing_address_json.\n\n### Fulfillment Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-fulfillment` |\n| Max width | lg |\n| Heading | \"Create fulfillment\" |\n\n**Content:**\n- For each unfulfilled order line: a row with checkbox, line title + unfulfilled quantity text, and a number input for quantity to fulfill (min 1, max unfulfilled quantity)\n- Separator\n- Tracking company input field (placeholder: \"UPS, FedEx, DHL...\")\n- Tracking number input field\n- Tracking URL input field (type: url)\n- Cancel button (ghost) and \"Create fulfillment\" button (primary)\n\n### Refund Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `create-refund` |\n| Max width | lg |\n| Heading | \"Refund order\" |\n\n**Content:**\n- For each order line: a row with checkbox, line title text, and a number input for quantity to refund (min 0, max original quantity)\n- Separator\n- \"Or enter custom amount\" number input field (placeholder: \"0.00\")\n- \"Reason\" textarea (3 rows, placeholder: \"Reason for refund...\")\n- Cancel button (ghost) and \"Create refund\" button (danger)\n\n### Flux Component Mapping\n\n| UI Element | Flux Component |\n|-----------|----------------|\n| Order number heading | `heading` |\n| Status badges | `badge` |\n| Action buttons | `button` |\n| Section headings | `heading` (size md) |\n| Section dividers | `separator` |\n| Text content | `text` |\n| Both modals | `modal` |\n| Modal form fields | `field`, `input`, `textarea` |\n| Line checkboxes | `checkbox` |\n| Modal buttons | `button` |\n| Icons | `icon` |\n| Callout messages | `callout` |\n\n---\n\n## 9. Customers List and Detail\n\n### 9.1 Customers List\n\n**Route:** `GET /admin/customers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by name or email |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `customers` | Paginated, searched customers with `withCount('orders')` and `withSum('orders', 'total_amount')` |\n\n#### Layout\n\n- Heading \"Customers\"\n- Search input\n- Table columns: Name, Email, Orders count, Total spent (formatted), Created date\n- Each row links to customer detail\n\n### 9.2 Customer Detail\n\n**Route:** `GET /admin/customers/{customer}`\n\n**Livewire component:** `App\\Livewire\\Admin\\Customers\\Show`\n\n**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `customer` | `Customer` | The customer model |\n| `editingAddress` | `?CustomerAddress` | Address currently being edited |\n| `addressLabel` | `string` | Label for address form |\n| `addressJson` | `array` | Address fields being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Customer $customer)` | Loads customer with orders and addresses |\n| `openAddressForm(?CustomerAddress $address)` | Opens address modal for create/edit |\n| `saveAddress()` | Saves the address |\n| `deleteAddress(int $addressId)` | Deletes an address |\n| `setDefaultAddress(int $addressId)` | Sets address as default |\n\n#### Two-Column Layout Wireframe\n\n```\n+----------------------------------------------+--------------------+\n|           LEFT COLUMN (2/3)                  | RIGHT COL (1/3)    |\n+----------------------------------------------+--------------------+\n| CUSTOMER INFO                                | ADDRESSES          |\n| Name: Jane Smith                             | Home (default)     |\n| Email: jane@example.com                      | 123 Main St        |\n| Created: Jan 1, 2024                         | Springfield, IL    |\n| Marketing: [Opted In]                        | [Edit] [Delete]    |\n|                                              |                    |\n| ORDER HISTORY                                | Office             |\n| Order # | Date      | Status | Total         | 456 Corp Ave       |\n| #1005   | Jan 20    | Paid   | $89.00        | Chicago, IL        |\n| #1001   | Jan 15    | Paid   | $131.36       | [Edit] [Delete]    |\n| < 1 2 >                                     |                    |\n|                                              | [+ Add address]    |\n+----------------------------------------------+--------------------+\n```\n\n**Left column:**\n- Customer info card: Name, email, created date, marketing opt-in status (Flux badge)\n- Order history table: columns Order #, Date, Status, Total. Each order links to order detail. Paginated.\n\n**Right column:**\n- Addresses card: List of addresses, each showing label, full address text, default badge (if `is_default`), Edit and Delete buttons\n- \"Add address\" button opens address modal\n\n#### Address Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `address-form` |\n| Max width | md |\n| Heading | \"Edit address\" or \"Add address\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"Home, Office...\")\n- Address line 1 input\n- Address line 2 input\n- City and State/Province inputs (side by side, 2-column grid)\n- ZIP/Postal code and Country select (side by side, 2-column grid)\n- Cancel button (ghost) and \"Save\" button (primary)\n\n---\n\n## 10. Discounts List and Form\n\n### 10.1 Discounts List\n\n**Route:** `GET /admin/discounts`\n\n**Livewire component:** `App\\Livewire\\Admin\\Discounts\\Index`\n\n**Blade view:** `resources/views/livewire/admin/discounts/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by discount code |\n| `statusFilter` | `string` | `all`, `active`, `expired`, `scheduled` |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `discounts` | Paginated, filtered discounts |\n\n#### Layout\n\n- Heading \"Discounts\" with \"Create discount\" primary button\n- Search input and status filter\n\n#### Table Columns\n\n| Column | Content |\n|--------|---------|\n| Code | Discount code or \"Automatic\" label |\n| Type | Flux badge: \"Code\" or \"Automatic\" |\n| Value | e.g., \"10%\" or \"$5.00\" or \"Free shipping\" |\n| Usage | \"3 / 100\" (used / limit) or \"3 / unlimited\" |\n| Status | Flux badge: active=green, expired=red, scheduled=yellow |\n| Dates | Start and end dates |\n\n### 10.2 Discount Form\n\n**Route:** `GET /admin/discounts/create` and `GET /admin/discounts/{discount}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Discounts\\Form`\n\n**Blade view:** `resources/views/livewire/admin/discounts/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `discount` | `?Discount` | The discount being edited |\n| `type` | `string` | `code` or `automatic` |\n| `code` | `string` | Discount code (if type is code) |\n| `valueType` | `string` | `percent`, `fixed`, `free_shipping` |\n| `valueAmount` | `?int` | Discount value (percentage or fixed amount) |\n| `minimumPurchaseAmount` | `?int` | Minimum cart value for eligibility |\n| `specificProductIds` | `array<int>` | Specific products the discount applies to |\n| `specificCollectionIds` | `array<int>` | Specific collections the discount applies to |\n| `usageLimit` | `?int` | Maximum total uses |\n| `onePerCustomer` | `bool` | Limit to one use per customer |\n| `startsAt` | `string` | Start datetime |\n| `endsAt` | `?string` | End datetime (optional) |\n| `isActive` | `bool` | Whether the discount is active |\n| `productSearch` | `string` | Search for products to add |\n| `collectionSearch` | `string` | Search for collections to add |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the discount |\n| `generateCode()` | Auto-generates a random discount code |\n| `addProduct(int $productId)` | Adds product to specific products list |\n| `removeProduct(int $productId)` | Removes product |\n| `addCollection(int $collectionId)` | Adds collection to specific collections |\n| `removeCollection(int $collectionId)` | Removes collection |\n\n#### Layout Wireframe (Single Column)\n\n```\n+==================================================================+\n| TYPE CARD                                                         |\n|  ( ) Discount code - Customers enter a code at checkout          |\n|  (o) Automatic discount - Applied automatically at checkout      |\n+==================================================================+\n| CODE CARD (shown when type = \"code\")                             |\n|  Discount code: [SUMMER20             ]  [Generate]              |\n+==================================================================+\n| VALUE CARD                                                        |\n|  ( ) Percentage                                                   |\n|  (o) Fixed amount                                                 |\n|  ( ) Free shipping                                                |\n|                                                                   |\n|  Amount: [5.00                        ] (hidden for free shipping)|\n+==================================================================+\n| CONDITIONS CARD                                                   |\n|  Minimum purchase amount: [0.00       ]                          |\n|  (Leave empty for no minimum)                                     |\n|                                                                   |\n|  Specific products:                                               |\n|  [Search products...          ]                                   |\n|  +---results dropdown---+                                         |\n|  Selected: Blue Shirt [x]  Red Hat [x]                           |\n|                                                                   |\n|  Specific collections: (same pattern as products)                 |\n+==================================================================+\n| USAGE LIMITS CARD                                                 |\n|  Total usage limit: [100              ] (or leave blank)         |\n|  [x] Limit to one use per customer                               |\n+==================================================================+\n| ACTIVE DATES CARD                                                 |\n|  Start date: [2024-01-15 00:00       ]                           |\n|  End date:   [2024-02-15 00:00       ] (or leave blank)          |\n+==================================================================+\n| STATUS                                                            |\n|  [switch: ON]  Active                                             |\n+==================================================================+\n| STICKY SAVE BAR                          [Discard]  [Save]       |\n+==================================================================+\n```\n\n#### Section Details\n\n**Type section:** Two radio buttons - \"Discount code\" (description: \"Customers enter a code at checkout\") and \"Automatic discount\" (description: \"Applied automatically at checkout\"). Selecting type is live-bound.\n\n**Code input:** Shown only when type = \"code\". Text input with placeholder \"SUMMER20\" and a \"Generate\" ghost button that auto-generates a random code.\n\n**Value section:** Three radio buttons for value type: Percentage, Fixed amount, Free shipping. When not free shipping, a number input appears for the amount (label changes based on type: \"Percentage\" or \"Amount\").\n\n**Conditions section:**\n- Minimum purchase amount: number input with description \"Leave empty for no minimum\"\n- Specific products: search input with live debounce. Results appear in a scrollable dropdown. Selected products shown as a list with remove (X) buttons.\n- Specific collections: same pattern as products.\n\n**Usage limits section:**\n- Total usage limit number input (placeholder: \"Unlimited\")\n- \"Limit to one use per customer\" checkbox\n\n**Active dates section:**\n- Start date: datetime-local input\n- End date: datetime-local input with description \"Leave empty for no end date\"\n\n**Status:** Toggle switch with text label \"Active\" or \"Disabled\".\n\nSticky save bar at the bottom (same pattern as product form).\n\n---\n\n## 11. Settings Pages\n\n### 11.1 General Settings\n\n**Route:** `GET /admin/settings`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\General`\n\n**Blade view:** `resources/views/livewire/admin/settings/general.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `storeName` | `string` | Store display name |\n| `storeHandle` | `string` | Store handle (read-only or with warning) |\n| `defaultCurrency` | `string` | Currency code (e.g., \"EUR\") |\n| `defaultLocale` | `string` | Locale code (e.g., \"en\") |\n| `timezone` | `string` | Timezone string |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves store settings |\n\n#### Layout\n\nTwo-column layout per section: left side has section label and description, right side has form fields.\n\n**Store details section:**\n- Left: Heading \"Store details\" + description \"Basic information about your store.\"\n- Right: Store name input, Store handle input (disabled, with description: \"The store handle cannot be changed after creation.\")\n\nSeparator between sections.\n\n**Defaults section:**\n- Left: Heading \"Defaults\" + description \"Currency, language, and timezone settings.\"\n- Right:\n  - Default currency: Select (EUR/USD/GBP and more)\n  - Default locale: Select (English/German/French and more)\n  - Timezone: Select (populated from PHP timezone list)\n\nSave button at the bottom (primary variant, right-aligned).\n\n### 11.2 Domains Settings\n\nDomains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Domains`\n\n**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `domains` | `Collection` | Store domains |\n| `newHostname` | `string` | Hostname for new domain |\n| `newType` | `string` | Type for new domain: `storefront`, `admin`, `api` |\n\n| Action | Behavior |\n|--------|----------|\n| `addDomain()` | Creates a new domain record |\n| `removeDomain(int $domainId)` | Removes a domain |\n| `setPrimary(int $domainId)` | Sets domain as primary |\n\n#### Layout\n\n- Heading \"Domains\"\n- Table of existing domains:\n\n| Column | Content |\n|--------|---------|\n| Hostname | Domain hostname |\n| Type | Flux badge: storefront/admin/api |\n| Primary | Flux badge (green) \"Primary\" if `is_primary` |\n| TLS | Flux badge showing TLS mode |\n| Actions | \"Set Primary\" button, \"Delete\" button |\n\n- \"Add domain\" button opens a modal\n\n#### Add Domain Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `add-domain` |\n| Max width | md |\n| Heading | \"Add domain\" |\n\n**Form fields:**\n- Hostname input (placeholder: \"shop.example.com\")\n- Type select: Storefront, Admin, API\n- Cancel button (ghost) and \"Add domain\" button (primary)\n\n### 11.3 Shipping Settings\n\n**Route:** `GET /admin/settings/shipping`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Shipping`\n\n**Blade view:** `resources/views/livewire/admin/settings/shipping.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `zones` | `Collection` | Shipping zones with rates |\n| `editingZone` | `?ShippingZone` | Zone being edited |\n| `zoneName` | `string` | Name for zone form |\n| `zoneCountries` | `array` | Selected countries for zone |\n| `editingRate` | `?ShippingRate` | Rate being edited |\n| `rateName` | `string` | Rate name |\n| `rateType` | `string` | `flat`, `weight`, `price`, `carrier` |\n| `rateConfig` | `array` | Rate configuration (depends on type) |\n| `rateActive` | `bool` | Whether rate is active |\n| `testAddress` | `array` | Address fields for test tool |\n| `testResult` | `?array` | Result of test address lookup |\n\n| Action | Behavior |\n|--------|----------|\n| `openZoneModal(?ShippingZone $zone)` | Opens zone create/edit modal |\n| `saveZone()` | Saves shipping zone |\n| `deleteZone(int $zoneId)` | Deletes a shipping zone |\n| `openRateModal(int $zoneId, ?ShippingRate $rate)` | Opens rate create/edit modal |\n| `saveRate()` | Saves shipping rate |\n| `deleteRate(int $rateId)` | Deletes a rate |\n| `testShippingAddress()` | Tests which zone/rate matches a given address |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| Shipping                                         [+ Add zone]    |\n+==================================================================+\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | ZONE: Domestic                            [Edit] [Delete]    | |\n| | Countries: US, CA                                             | |\n| |                                                               | |\n| | Name      | Type    | Config           | Active | Actions    | |\n| |-----------|---------|------------------|--------|------------|  |\n| | Standard  | flat    | $5.00            | [on]   | [Ed] [Del]| |\n| | Express   | flat    | $15.00           | [on]   | [Ed] [Del]| |\n| | [+ Add rate]                                                  | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | ZONE: Europe                              [Edit] [Delete]    | |\n| | Countries: DE, FR, GB, ...                                    | |\n| | (rates table...)                                              | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n| TEST SHIPPING ADDRESS                                             |\n| Country: [v]  State: [    ]  City: [    ]  ZIP: [    ]           |\n| [Test]                                                            |\n| Result: Matched zone: Domestic                                    |\n|         Standard - $5.00 / Express - $15.00                      |\n+==================================================================+\n```\n\nEach zone is displayed as a card with:\n- Zone name heading and Edit/Delete buttons in the header\n- Countries list as muted text\n- Rates table inside the zone card with columns: Name, Type (as badge), Config summary, Active (toggle switch), Actions (Edit/Delete buttons)\n- \"Add rate\" button at the bottom of each zone card\n\n\"Add zone\" button at the top of the page.\n\n#### Zone Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `zone-form` |\n| Max width | md |\n| Heading | \"Edit shipping zone\" or \"Add shipping zone\" (dynamic) |\n\n**Form fields:**\n- Zone name input (placeholder: \"Domestic, Europe, International...\")\n- Countries: scrollable checklist of all available countries with checkboxes\n- Cancel button (ghost) and \"Save zone\" button (primary)\n\n#### Rate Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `rate-form` |\n| Max width | md |\n| Heading | \"Edit shipping rate\" or \"Add shipping rate\" (dynamic) |\n\n**Form fields:**\n- Rate name input (placeholder: \"Standard, Express...\")\n- Rate type select: Flat rate, Weight-based, Price-based, Carrier-calculated\n- Dynamic config fields based on rate type:\n  - **Flat:** Price input\n  - **Weight-based:** Min weight (g) input, Max weight (g) input, Price input\n  - **Price-based:** Min order amount input, Max order amount input, Price input\n  - **Carrier:** Info callout: \"Carrier-calculated rates require a carrier integration to be configured.\"\n- Active toggle switch with \"Active\" text label\n- Cancel button (ghost) and \"Save rate\" button (primary)\n\n#### Test Shipping Address Tool\n\nA card section at the bottom of the page:\n- Heading \"Test shipping address\"\n- Description: \"Enter an address to see which shipping zone and rates match.\"\n- 2-column grid of fields: Country (select), State/Region (input), City (input), ZIP/Postal code (input)\n- \"Test\" button\n- Result area (shown after test):\n  - If matched: \"Matched zone: [zone name]\" and list of available rates with prices\n  - If no match: Warning callout \"No shipping zone matches this address.\"\n\n### 11.4 Tax Settings\n\n**Route:** `GET /admin/settings/taxes`\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Taxes`\n\n**Blade view:** `resources/views/livewire/admin/settings/taxes.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `mode` | `string` | `manual` or `provider` |\n| `pricesIncludeTax` | `bool` | Whether prices include tax |\n| `provider` | `string` | Provider name (e.g., `stripe_tax`) |\n| `providerApiKey` | `string` | API key for tax provider |\n| `manualRates` | `array` | Array of `{zone_name, rate_percentage}` |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Saves tax settings |\n| `addManualRate()` | Adds a new empty rate row |\n| `removeManualRate(int $index)` | Removes a manual rate |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| TAXES                                                             |\n+==================================================================+\n| MODE SELECTION                                                    |\n|  ( ) Manual tax rates - Define tax rates per zone manually       |\n|  (o) Tax provider - Use an automated tax calculation service     |\n+==================================================================+\n| MANUAL RATES TABLE (shown when mode = \"manual\")                  |\n| Zone name       | Rate (%)    |                                   |\n|-----------------|-------------|                                   |\n| [EU           ] | [19.00    ] | [trash]                          |\n| [US-CA        ] | [7.25     ] | [trash]                          |\n| [+ Add rate]                                                      |\n+==================================================================+\n| PROVIDER CONFIG (shown when mode = \"provider\")                   |\n|  Provider: [Stripe Tax v]                                        |\n|  API key:  [*************]                                       |\n+==================================================================+\n| [separator]                                                       |\n| TAX-INCLUSIVE TOGGLE                                              |\n|  [switch: ON]  Prices include tax                                |\n|  Description: When enabled, the listed price includes tax.       |\n|  Tax is calculated backwards from the price.                      |\n+==================================================================+\n| [Save]                                                            |\n+==================================================================+\n```\n\n**Mode selection:** Two radio buttons - \"Manual tax rates\" (description: \"Define tax rates per zone manually\") and \"Tax provider\" (description: \"Use an automated tax calculation service\"). Live-bound.\n\n**Manual rates table:** Shown when mode = \"manual\". Inline-editable table with columns: Zone name (text input), Rate % (number input with step 0.01). Each row has a trash button. \"Add rate\" ghost button with plus icon.\n\n**Provider config:** Shown when mode = \"provider\". Provider select (Stripe Tax / None) and API key password input.\n\n**Tax-inclusive toggle:** Toggle switch with \"Prices include tax\" label and description text explaining the behavior.\n\nSave button at the bottom.\n\n---\n\n## 12. Themes Management\n\n### 12.1 Themes List\n\n**Route:** `GET /admin/themes`\n\n**Livewire component:** `App\\Livewire\\Admin\\Themes\\Index`\n\n**Blade view:** `resources/views/livewire/admin/themes/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `themes` | `Collection` | All themes for the store |\n\n| Action | Behavior |\n|--------|----------|\n| `publishTheme(int $themeId)` | Publishes a theme (sets as active) |\n| `duplicateTheme(int $themeId)` | Creates a copy of the theme |\n| `deleteTheme(int $themeId)` | Deletes a theme |\n\n#### Layout Wireframe\n\n```\n+==================================================================+\n| Themes                                                            |\n+==================================================================+\n|                                                                   |\n| +------------------+ +------------------+ +------------------+   |\n| | [preview image]  | | [preview image]  | | [preview image]  |   |\n| |                  | |                  | |                  |   |\n| | My Theme   v1.0  | | Dark Theme v1.2  | | Minimal    v2.0  |   |\n| | [Published]      | | [Draft]          | | [Draft]          |   |\n| |                  | |                  | |                  |   |\n| | [Customize] [...] | | [Customize] [...] | | [Customize] [...] |   |\n| +------------------+ +------------------+ +------------------+   |\n|                                                                   |\n+==================================================================+\n```\n\n**Theme grid:** Responsive 3-column grid (1 on mobile, 2 on md, 3 on lg).\n\nEach theme card contains:\n- **Thumbnail area:** Aspect-video container with theme preview image or placeholder\n- **Info section:**\n  - Theme name (heading, size md) and version text\n  - Status badge (published=green, draft=zinc)\n- **Actions:**\n  - \"Customize\" primary button (links to theme editor)\n  - More actions dropdown (triggered by ellipsis icon button):\n    - \"Preview\" menu item\n    - \"Publish\" menu item (hidden if already published)\n    - \"Duplicate\" menu item\n    - Separator\n    - \"Delete\" menu item (styled red/danger)\n\nThe published theme card has a blue ring border highlight.\n\n### 12.2 Theme Editor\n\n**Route:** `GET /admin/themes/{theme}/editor`\n\n**Livewire component:** `App\\Livewire\\Admin\\Themes\\Editor`\n\n**Blade view:** `resources/views/livewire/admin/themes/editor.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `theme` | `Theme` | The theme being edited |\n| `sections` | `array` | List of theme sections from settings schema |\n| `selectedSection` | `?string` | Currently selected section key |\n| `sectionSettings` | `array` | Settings values for the selected section |\n| `previewUrl` | `string` | URL for the live preview iframe |\n\n| Action | Behavior |\n|--------|----------|\n| `mount(Theme $theme)` | Loads theme and its sections |\n| `selectSection(string $sectionKey)` | Changes the selected section and loads its settings |\n| `updateSetting(string $key, mixed $value)` | Updates a setting value |\n| `save()` | Saves all section settings to theme_settings |\n| `publish()` | Publishes the theme |\n| `refreshPreview()` | Reloads the preview iframe |\n\n#### Layout Wireframe (Full Width, Three-Panel)\n\n```\n+==================================================================+\n| [<- Back to themes]                     [Save]  [Save & publish] |\n+==================================================================+\n| SECTIONS   |  LIVE PREVIEW (iframe)       |  SETTINGS PANEL      |\n| (264px)    |  (flexible width)            |  (320px)             |\n|            |                              |                      |\n| Header     |  +------------------------+ |  Header Settings     |\n| Hero       |  |                        | |  [separator]         |\n| Features*  |  |  Live storefront       | |                      |\n| Products   |  |  preview rendered      | |  Logo text:          |\n| Footer     |  |  in an iframe          | |  [My Store        ]  |\n|            |  |                        | |                      |\n|            |  |                        | |  Background color:   |\n|            |  |                        | |  [color picker]      |\n|            |  |                        | |                      |\n|            |  |                        | |  Show search:        |\n|            |  |                        | |  [x] checkbox        |\n|            |  |                        | |                      |\n|            |  +------------------------+ |  Layout:             |\n|            |                              |  [Centered       v]  |\n+==================================================================+\n  * = currently selected section (highlighted)\n```\n\n**Full-width layout** (no standard admin shell padding). Three-panel layout:\n\n**Top toolbar:** A horizontal bar with:\n- \"Back to themes\" button (ghost, with left arrow icon) linking to themes index\n- \"Save\" ghost button and \"Save and publish\" primary button\n\n**Left panel (264px):** Section list\n- Heading \"Sections\"\n- Vertical list of section buttons\n- Active section gets highlighted background and bold text\n- Clicking a section calls `selectSection()`\n\n**Center panel (flexible):** Live preview\n- Background color behind the iframe\n- Full-height iframe loading the storefront preview URL\n- Iframe has rounded border and shadow\n\n**Right panel (320px):** Settings form\n- Shows settings for the selected section\n- Heading with section label\n- Separator\n- Dynamic form fields based on section field definitions:\n\n| Field Type | UI Element |\n|-----------|------------|\n| `text` | Flux input (live-debounced at 500ms) |\n| `textarea` | Flux textarea (live-debounced at 500ms, 3 rows) |\n| `color` | Native color picker input |\n| `select` | Flux select with options from field definition |\n| `checkbox` | Flux checkbox |\n\n- When no section is selected: centered text \"Select a section to edit its settings.\"\n\n---\n\n## 13. Pages Management\n\n### 13.1 Pages List\n\n**Route:** `GET /admin/pages`\n\n**Livewire component:** `App\\Livewire\\Admin\\Pages\\Index`\n\n**Blade view:** `resources/views/livewire/admin/pages/index.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `search` | `string` | Search by page title |\n\n| Computed Property | Purpose |\n|-------------------|---------|\n| `pages` | Paginated pages for the current store |\n\n#### Layout\n\n- Heading \"Pages\" with \"Add page\" primary button\n- Search input\n- Table columns: Title (link to edit), Handle, Status (Flux badge), Updated date\n- Pagination\n\n### 13.2 Page Form\n\n**Route:** `GET /admin/pages/create` and `GET /admin/pages/{page}/edit`\n\n**Livewire component:** `App\\Livewire\\Admin\\Pages\\Form`\n\n**Blade view:** `resources/views/livewire/admin/pages/form.blade.php`\n\n#### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `page` | `?Page` | Page being edited |\n| `title` | `string` | Page title |\n| `handle` | `string` | URL handle |\n| `bodyHtml` | `string` | Page body content (HTML) |\n| `status` | `string` | `draft` or `active` |\n| `publishedAt` | `?string` | Publish date |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves the page |\n| `deletePage()` | Deletes the page |\n\n#### Layout (Two-Column)\n\n**Left column (2/3):**\n- Title: Flux input\n- Handle: Flux input\n- Body: Flux textarea (16 rows)\n\n**Right column (1/3):**\n- Status: Flux select with Draft/Active\n- Published at: Datetime-local input\n\nSticky save bar (same pattern as other forms).\n\n---\n\n## 14. Navigation Management\n\n**Route:** `GET /admin/navigation`\n\n**Livewire component:** `App\\Livewire\\Admin\\Navigation\\Index`\n\n**Blade view:** `resources/views/livewire/admin/navigation/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `menus` | `Collection` | Navigation menus (Main Menu, Footer Menu) |\n| `editingMenu` | `?NavigationMenu` | Menu being edited |\n| `menuItems` | `array` | Nested array of menu items for the editing menu |\n| `editingItem` | `?array` | Item currently being edited in the modal |\n| `editingItemIndex` | `?int` | Index of item being edited |\n| `itemLabel` | `string` | Item label |\n| `itemType` | `string` | `link`, `page`, `collection`, `product` |\n| `itemUrl` | `string` | URL if type is link |\n| `itemResourceId` | `?int` | Resource ID if type is page/collection/product |\n\n| Action | Behavior |\n|--------|----------|\n| `selectMenu(int $menuId)` | Loads a menu for editing |\n| `addItem()` | Opens the item form modal in create mode |\n| `editItem(int $index)` | Opens the item form modal in edit mode |\n| `saveItem()` | Saves the item (add or update) |\n| `removeItem(int $index)` | Removes an item |\n| `reorderItems(array $order)` | Updates item positions (supports nesting) |\n| `saveMenu()` | Persists all menu items |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Navigation                                                        |\n+==================================================================+\n|                                                                   |\n| MENUS                                                             |\n| +----------------------------+  +----------------------------+   |\n| | Main Menu        [Edit]    |  | Footer Menu       [Edit]   |   |\n| +----------------------------+  +----------------------------+   |\n|                                                                   |\n| MENU EDITOR (shown when a menu is selected)                      |\n| +--------------------------------------------------------------+ |\n| | Main Menu                                    [+ Add item]    | |\n| |                                                               | |\n| | [drag] Home               link: /            [edit] [trash]  | |\n| | [drag] Products           collection: All    [edit] [trash]  | |\n| | [drag] About Us           page: About        [edit] [trash]  | |\n| | [drag] Contact            link: /contact     [edit] [trash]  | |\n| |                                                               | |\n| |                                            [Save menu]       | |\n| +--------------------------------------------------------------+ |\n+==================================================================+\n```\n\n**Menu list:** Each menu displayed as a card with title and \"Edit\" button.\n\n**Menu editor (shown when a menu is selected):**\n- Menu title as heading\n- \"Add item\" button (ghost, plus icon) in the header\n- Sortable list of menu items, each row containing:\n  - Drag handle (bars icon)\n  - Item label (bold text)\n  - Item type and target (small, muted text: e.g., \"link: /about\" or \"collection: Summer Sale\")\n  - Edit button (pencil icon) and Delete button (trash icon)\n- \"Save menu\" primary button at the bottom\n\n#### Menu Item Form Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `item-form` |\n| Max width | md |\n| Heading | \"Edit menu item\" or \"Add menu item\" (dynamic) |\n\n**Form fields:**\n- Label input (placeholder: \"About Us\")\n- Type select: Custom link, Page, Collection, Product (live-bound)\n- Conditional fields based on type:\n  - **Custom link:** URL input (placeholder: \"https://...\")\n  - **Page/Collection/Product:** Resource select dropdown populated with available resources of that type\n- Cancel button (ghost) and \"Save item\" button (primary)\n\n---\n\n## 15. Apps Page\n\n**Route:** `GET /admin/apps`\n\n**Livewire component:** `App\\Livewire\\Admin\\Apps\\Index`\n\n**Blade view:** `resources/views/livewire/admin/apps/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `installedApps` | `Collection` | Apps installed for this store |\n\n| Action | Behavior |\n|--------|----------|\n| `uninstallApp(int $appId)` | Uninstalls an app (with confirmation) |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Apps                                                              |\n+==================================================================+\n|                                                                   |\n| +--------------------------------------------------------------+ |\n| | [icon]  My Integration App                         [Active]  | |\n| |         Installed 3 months ago                               | |\n| +--------------------------------------------------------------+ |\n| +--------------------------------------------------------------+ |\n| | [icon]  Analytics Plugin                          [Inactive] | |\n| |         Installed 1 week ago                                 | |\n| +--------------------------------------------------------------+ |\n|                                                                   |\n+==================================================================+\n```\n\nList of installed app cards, each containing:\n- App icon placeholder (squares icon in a rounded container)\n- App name (heading, size md)\n- Install date (relative timestamp, muted text)\n- Status badge (active=green, inactive=zinc)\n\nEach app links to a detail page showing:\n- Scopes granted (list of permission strings)\n- Webhook subscriptions table: event type, URL, status\n- Usage stats (API calls count, last call timestamp)\n\n**Empty state:** \"No apps installed\" with descriptive text.\n\n---\n\n## 16. Developers Page\n\n**Route:** `GET /admin/developers`\n\n**Livewire component:** `App\\Livewire\\Admin\\Developers\\Index`\n\n**Blade view:** `resources/views/livewire/admin/developers/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `tokens` | `Collection` | API tokens for the store |\n| `webhooks` | `Collection` | Webhook subscriptions |\n| `newTokenName` | `string` | Name for new token |\n| `generatedToken` | `?string` | Newly generated token (shown once) |\n| `webhookEventType` | `string` | Event type for webhook form |\n| `webhookUrl` | `string` | URL for webhook form |\n| `editingWebhook` | `?WebhookSubscription` | Webhook being edited |\n\n| Action | Behavior |\n|--------|----------|\n| `generateToken()` | Creates a new API token |\n| `revokeToken(int $tokenId)` | Revokes a token |\n| `openWebhookModal(?WebhookSubscription $webhook)` | Opens webhook create/edit modal |\n| `saveWebhook()` | Saves webhook subscription |\n| `deleteWebhook(int $webhookId)` | Deletes a webhook |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Developers                                                        |\n+==================================================================+\n|                                                                   |\n| API TOKENS                                                        |\n| Manage personal access tokens for the Admin API.                 |\n|                                                                   |\n| Name           | Last used      | Created       | Actions        |\n|----------------|----------------|---------------|----------------|\n| My integration | 2 hours ago    | Jan 1, 2024   | [Revoke]       |\n| CI Pipeline    | Never          | Feb 10, 2024  | [Revoke]       |\n|                                                                   |\n| [Generate new token]                                              |\n|                                                                   |\n| (after generating, shown once:)                                   |\n| [!] Copy this token now. It will not be shown again.             |\n| [sk_live_abc123xyz789...]                                        |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| WEBHOOKS                                                          |\n| Manage webhook subscriptions for real-time event notifications.  |\n|                                                                   |\n| Event type           | URL                    | Status  | Act    |\n|----------------------|------------------------|---------|--------|\n| order.created        | https://ex.com/hook    | Active  | [E][D] |\n| product.updated      | https://ex.com/prod    | Failing | [E][D] |\n|                                                                   |\n| [+ Add webhook]                                                   |\n+==================================================================+\n```\n\nTwo sections separated by a Flux separator.\n\n**API Tokens section:**\n- Heading \"API tokens\" (size lg)\n- Description text\n- Token table with columns: Name, Last used (relative or \"Never\"), Created (date), Actions (Revoke button)\n- \"Generate new token\" button opens modal\n- When a token is generated: a warning callout is displayed with the token value and a note that it will only be shown once\n\n#### Generate Token Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `generate-token` |\n| Max width | md |\n| Heading | \"Generate API token\" |\n\n**Form fields:**\n- Token name input (placeholder: \"My integration\")\n- Cancel button (ghost) and \"Generate\" button (primary)\n\n**Webhooks section:**\n- Heading \"Webhooks\" (size lg)\n- Description text\n- Webhook table with columns:\n\n| Column | Content |\n|--------|---------|\n| Event type | e.g., `order.created`, `product.updated` |\n| URL | Endpoint URL |\n| Status | Flux badge: active=green, failing=red |\n| Actions | Edit and Delete buttons |\n\n- \"Add webhook\" button opens modal\n\n#### Webhook Modal\n\n| Property | Value |\n|----------|-------|\n| Modal name | `webhook-form` |\n| Max width | md |\n| Heading | \"Edit webhook\" or \"Add webhook\" (dynamic) |\n\n**Form fields:**\n- Event type select with options: order.created, order.updated, order.cancelled, product.created, product.updated, product.deleted, customer.created, checkout.completed, fulfillment.created, refund.created\n- Endpoint URL input (type: url, placeholder: \"https://example.com/webhooks\")\n- Cancel button (ghost) and \"Save\" button (primary)\n\n---\n\n## 17. Analytics Page\n\n**Route:** `GET /admin/analytics`\n\n**Livewire component:** `App\\Livewire\\Admin\\Analytics\\Index`\n\n**Blade view:** `resources/views/livewire/admin/analytics/index.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `dateRange` | `string` | Date range preset |\n| `customStartDate` | `?string` | Custom start date |\n| `customEndDate` | `?string` | Custom end date |\n| `channelFilter` | `string` | Sales channel filter (`all`, `storefront`, `api`) |\n| `deviceFilter` | `string` | Device filter (`all`, `desktop`, `mobile`, `tablet`) |\n| `totalSales` | `int` | Total sales for the period |\n| `ordersCount` | `int` | Orders count |\n| `averageOrderValue` | `int` | AOV |\n| `conversionRate` | `float` | Conversion rate percentage |\n| `salesChartData` | `array` | Daily sales chart data |\n| `topProducts` | `Collection` | Top products by revenue |\n| `topReferrers` | `array` | Top traffic sources |\n| `isExporting` | `bool` | Whether an export is in progress |\n| `exportUrl` | `?string` | Download URL when export is ready |\n\n| Action | Behavior |\n|--------|----------|\n| `updatedDateRange()` | Reloads analytics data |\n| `updatedChannelFilter()` | Reloads with channel filter |\n| `updatedDeviceFilter()` | Reloads with device filter |\n| `loadAnalytics()` | Master method to reload all analytics data |\n| `exportCsv()` | Dispatches an async job to generate a CSV export |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Analytics                                                         |\n+==================================================================+\n| [Today v]  [Channel: All v]  [Device: All v]  [Export CSV]       |\n+==================================================================+\n|                                                                   |\n| KPI TILES (same as Dashboard + Conversion Rate)                  |\n| +------------+ +------------+ +------------+ +-----------+       |\n| | Total Sales| | Orders     | | AOV        | | Conv Rate |       |\n| | $45,678    | | 523        | | $87.34     | | 3.2%      |       |\n| +------------+ +------------+ +------------+ +-----------+       |\n|                                                                   |\n| SALES CHART (larger than dashboard version)                      |\n| [line/bar chart - daily revenue/orders]                          |\n|                                                                   |\n| TOP PRODUCTS (10-20 rows)                                         |\n| Rank | Product     | Units Sold | Revenue  | % of Total          |\n| 1    | Blue Shirt  | 145        | $14,500  | 31.7%               |\n| 2    | Red Hat     | 98         | $9,800   | 21.4%               |\n| ...                                                               |\n|                                                                   |\n| TOP REFERRERS                                                     |\n| Source          | Sessions | Orders | Conversion Rate             |\n| Google          | 1,200    | 45     | 3.75%                      |\n| Direct          | 800      | 32     | 4.00%                      |\n| Instagram       | 450      | 12     | 2.67%                      |\n+==================================================================+\n```\n\nSimilar to Dashboard but with additional controls and detail.\n\n**Filter bar:**\n- Date range dropdown (same as Dashboard: Today, Last 7 days, Last 30 days, Custom)\n- Channel filter: Flux select with All, Storefront, API\n- Device filter: Flux select with All, Desktop, Mobile, Tablet\n- Export button: ghost variant with download icon - calls `exportCsv()`\n\nWhen `isExporting` is true, show a loading indicator next to the export button. When `exportUrl` is set, show a download link.\n\n**KPI tiles:** Same as Dashboard (Total Sales, Orders, AOV, Conversion Rate) but with the additional filters applied.\n\n**Sales chart:** Larger chart than Dashboard, line or bar chart showing daily revenue/orders.\n\n**Top Products table:** Extended version with 10-20 rows. Columns: Rank, Product, Units Sold, Revenue, Percentage of Total.\n\n**Top Referrers table:** Columns: Traffic source, Sessions, Orders, Conversion Rate.\n\n---\n\n## 18. Search Settings\n\n**Route:** `GET /admin/search/settings`\n\n**Livewire component:** `App\\Livewire\\Admin\\Search\\Settings`\n\n**Blade view:** `resources/views/livewire/admin/search/settings.blade.php`\n\n### Component Requirements\n\n| Property | Type | Purpose |\n|----------|------|---------|\n| `synonymGroups` | `array` | Array of synonym groups, each a comma-separated string |\n| `stopWords` | `string` | Comma-separated stop words |\n| `lastIndexedAt` | `?string` | Last reindex timestamp |\n| `isReindexing` | `bool` | Whether a reindex is in progress |\n| `reindexProgress` | `?int` | Progress percentage (0-100) |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Saves synonym and stop word settings |\n| `addSynonymGroup()` | Adds a new empty synonym group |\n| `removeSynonymGroup(int $index)` | Removes a synonym group |\n| `triggerReindex()` | Dispatches a reindex job |\n| `pollReindexStatus()` | Checks reindex progress (polled every 2 seconds) |\n\n### Layout Wireframe\n\n```\n+==================================================================+\n| Search Settings                                                   |\n+==================================================================+\n|                                                                   |\n| SYNONYMS                                                          |\n| Define groups of words that should be treated as equivalent.     |\n|                                                                   |\n| [t-shirt, tee, tshirt              ] [trash]                    |\n| [sneakers, trainers, kicks         ] [trash]                    |\n| [+ Add synonym group]                                             |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| STOP WORDS                                                        |\n| Words that are excluded from search indexing.                    |\n| [textarea: the, a, an, is, are...           ]                   |\n| (Separate words with commas.)                                     |\n|                                                                   |\n| [separator]                                                       |\n|                                                                   |\n| SEARCH INDEX                                                      |\n| [Reindex now]  Last indexed: Jan 15, 2024 3:45 PM               |\n|                                                                   |\n| (when reindexing:)                                                |\n| [==============          ] 65% complete                          |\n|                                                                   |\n| [Save]                                                            |\n+==================================================================+\n```\n\n**Synonyms section:**\n- Heading \"Synonyms\" (size lg)\n- Description text\n- For each synonym group: a row with text input (placeholder: \"t-shirt, tee, tshirt\") and trash button\n- \"Add synonym group\" ghost button with plus icon\n\n**Stop words section:**\n- Separator\n- Heading \"Stop words\" (size lg)\n- Description text\n- Textarea (4 rows, placeholder: \"the, a, an, is, are...\")\n- Flux description: \"Separate words with commas.\"\n\n**Reindex section:**\n- Separator\n- Heading \"Search index\" (size lg)\n- \"Reindex now\" button (disabled while reindexing, text changes to \"Reindexing...\")\n- Last indexed timestamp shown beside button (if available)\n- When reindexing: a progress bar (polled every 2 seconds) with percentage text\n\nSave button at the bottom.\n\n---\n\n## 19. Common Patterns and Conventions\n\n### 19.1 List Pages\n\nAll list pages follow a consistent pattern:\n1. Page heading with optional action button (top-right)\n2. Search input with live debounce (300ms)\n3. Filter controls (dropdowns, tabs, or both)\n4. Data table with sortable columns\n5. Pagination at the bottom\n6. Empty state when no records exist (and no filters are active)\n7. Loading states: table body shows reduced opacity during data loading\n\n### 19.2 Form Pages\n\nAll form pages follow a consistent pattern:\n1. Page heading with breadcrumbs\n2. Two-column layout (2/3 + 1/3 on large screens, stacked on mobile)\n3. Left column: primary content fields\n4. Right column: status, metadata, and secondary fields\n5. Sticky save bar at the bottom with \"Discard\" and \"Save\" buttons\n6. Inline validation errors using Flux field component with error display\n7. Loading indicator on save: save button shows loading text and becomes disabled\n8. Success toast dispatched after save\n9. Delete action behind a confirmation Flux modal\n\n### 19.3 Delete Confirmations\n\nEvery destructive action uses a Flux modal for confirmation:\n- Heading describing the action\n- Body text explaining consequences\n- Cancel button (ghost variant) that closes the modal\n- Confirm button (danger variant) that executes the destructive action\n\n### 19.4 Toast Notifications\n\nAll successful actions dispatch a success toast (e.g., \"Changes saved.\"). Error states dispatch an error toast (e.g., \"Something went wrong. Please try again.\").\n\n### 19.5 Breadcrumbs\n\nEvery page includes Flux breadcrumbs:\n- First item: \"Home\" linking to admin dashboard\n- Intermediate items: parent section (e.g., \"Products\" linking to products index)\n- Last item: current page title (no link)\n\n### 19.6 Loading States\n\nAll interactive elements show loading states:\n- **Buttons:** Disabled attribute during loading, text swaps to loading message (e.g., \"Saving...\")\n- **Tables:** Body rows show reduced opacity during data fetching\n- **Charts/tiles:** Spinner overlay targeting the specific loading method\n\n### 19.7 Card Pattern\n\nReusable card wrapper used throughout the admin:\n- White background (dark: zinc-900)\n- Rounded corners (lg)\n- Border (zinc-200, dark: zinc-700)\n- Padding (p-6)\n\n### 19.8 Responsive Design\n\n- All layouts use Tailwind responsive breakpoints: `sm:`, `md:`, `lg:`, `xl:`\n- Sidebar collapses on screens below `lg`\n- Two-column form layouts stack to single column below `lg`\n- Tables become horizontally scrollable on small screens\n- Grid columns reduce on smaller screens (e.g., 4 cols on lg, 2 on sm, 1 on mobile)\n\n### 19.9 Accessibility\n\n- All form inputs have associated Flux label elements\n- Interactive elements are keyboard-navigable\n- Focus states are visible (Tailwind's default focus ring utilities)\n- Flux modal handles focus trapping and Escape key dismissal\n- Color is never the sole indicator of state (badges include text labels)\n- Semantic HTML: headings hierarchy, landmark regions, lists for navigation\n- ARIA attributes where needed (e.g., `aria-expanded` on collapsibles, `aria-current=\"page\"` on active nav)\n\n---\n\n## 20. Flash / Toast Messages\n\nAll flash messages use the toast notification component. Messages are displayed after the relevant action completes.\n\n| Action | Message | Type |\n|--------|---------|------|\n| Product created | \"Product saved\" | success |\n| Product updated | \"Product saved\" | success |\n| Product archived | \"Product archived\" | success |\n| Variant added | \"Variant saved\" | success |\n| Collection saved | \"Collection saved\" | success |\n| Discount created | \"Discount saved\" | success |\n| Discount updated | \"Discount saved\" | success |\n| Order fulfilled | \"Fulfillment created\" | success |\n| Refund issued | \"Refund issued\" | success |\n| Customer created | \"Customer saved\" | success |\n| Customer updated | \"Customer saved\" | success |\n| Settings saved | \"Settings saved\" | success |\n| Theme published | \"Theme published\" | success |\n| Page saved | \"Page saved\" | success |\n| Navigation saved | \"Navigation saved\" | success |\n| Login failed | \"Invalid credentials\" | error |\n| Validation error | Dynamic field-specific message | error |\n\n---\n\n## 21. Livewire Component Summary\n\n| Component Class | Blade View | Route(s) |\n|----------------|-----------|---------|\n| `App\\Livewire\\Admin\\Layout\\Sidebar` | `livewire.admin.layout.sidebar` | Layout (all pages) |\n| `App\\Livewire\\Admin\\Layout\\TopBar` | `livewire.admin.layout.top-bar` | Layout (all pages) |\n| `App\\Livewire\\Admin\\Dashboard` | `livewire.admin.dashboard` | `GET /admin` |\n| `App\\Livewire\\Admin\\Products\\Index` | `livewire.admin.products.index` | `GET /admin/products` |\n| `App\\Livewire\\Admin\\Products\\Form` | `livewire.admin.products.form` | `GET /admin/products/create`, `GET /admin/products/{product}/edit` |\n| `App\\Livewire\\Admin\\Collections\\Index` | `livewire.admin.collections.index` | `GET /admin/collections` |\n| `App\\Livewire\\Admin\\Collections\\Form` | `livewire.admin.collections.form` | `GET /admin/collections/create`, `GET /admin/collections/{collection}/edit` |\n| `App\\Livewire\\Admin\\Inventory\\Index` | `livewire.admin.inventory.index` | `GET /admin/inventory` |\n| `App\\Livewire\\Admin\\Orders\\Index` | `livewire.admin.orders.index` | `GET /admin/orders` |\n| `App\\Livewire\\Admin\\Orders\\Show` | `livewire.admin.orders.show` | `GET /admin/orders/{order}` |\n| `App\\Livewire\\Admin\\Customers\\Index` | `livewire.admin.customers.index` | `GET /admin/customers` |\n| `App\\Livewire\\Admin\\Customers\\Show` | `livewire.admin.customers.show` | `GET /admin/customers/{customer}` |\n| `App\\Livewire\\Admin\\Discounts\\Index` | `livewire.admin.discounts.index` | `GET /admin/discounts` |\n| `App\\Livewire\\Admin\\Discounts\\Form` | `livewire.admin.discounts.form` | `GET /admin/discounts/create`, `GET /admin/discounts/{discount}/edit` |\n| `App\\Livewire\\Admin\\Settings\\General` | `livewire.admin.settings.general` | `GET /admin/settings` |\n| `App\\Livewire\\Admin\\Settings\\Domains` | `livewire.admin.settings.domains` | Tab within `GET /admin/settings` |\n| `App\\Livewire\\Admin\\Settings\\Shipping` | `livewire.admin.settings.shipping` | `GET /admin/settings/shipping` |\n| `App\\Livewire\\Admin\\Settings\\Taxes` | `livewire.admin.settings.taxes` | `GET /admin/settings/taxes` |\n| `App\\Livewire\\Admin\\Themes\\Index` | `livewire.admin.themes.index` | `GET /admin/themes` |\n| `App\\Livewire\\Admin\\Themes\\Editor` | `livewire.admin.themes.editor` | `GET /admin/themes/{theme}/editor` |\n| `App\\Livewire\\Admin\\Pages\\Index` | `livewire.admin.pages.index` | `GET /admin/pages` |\n| `App\\Livewire\\Admin\\Pages\\Form` | `livewire.admin.pages.form` | `GET /admin/pages/create`, `GET /admin/pages/{page}/edit` |\n| `App\\Livewire\\Admin\\Navigation\\Index` | `livewire.admin.navigation.index` | `GET /admin/navigation` |\n| `App\\Livewire\\Admin\\Apps\\Index` | `livewire.admin.apps.index` | `GET /admin/apps` |\n| `App\\Livewire\\Admin\\Developers\\Index` | `livewire.admin.developers.index` | `GET /admin/developers` |\n| `App\\Livewire\\Admin\\Analytics\\Index` | `livewire.admin.analytics.index` | `GET /admin/analytics` |\n| `App\\Livewire\\Admin\\Search\\Settings` | `livewire.admin.search.settings` | `GET /admin/search/settings` |\n","structuredPatch":[{"oldStart":1048,"oldLines":11,"newStart":1048,"newLines":22,"lines":[" "," #### Payment Details"," A card showing:","-- Provider (Stripe/PayPal) as text","-- Status as Flux badge","-- Amount","-- Provider payment ID (linked if applicable)","+- Payment method as text (Credit Card / PayPal / Bank Transfer), derived from `order.payment_method` enum","+- Status as Flux badge (pending=zinc, captured=green, failed=red, refunded=yellow)","+- Amount formatted as currency","+- Mock reference ID (provider_payment_id)","+- **\"Confirm payment\" button** (primary variant): Shown only when `payment_method = bank_transfer` AND `financial_status = pending`. Clicking calls `confirmPayment()`."," ","+#### Fulfillment Cards","+","+Each existing fulfillment is rendered as a card below the timeline. Each card shows:","+- Fulfillment status badge (pending=zinc, shipped=blue, delivered=green)","+- Tracking info (company, number, URL link)","+- Line items included in the fulfillment","+- **Action buttons on each fulfillment card:**","+  - \"Mark as shipped\" button (shown when fulfillment status is `pending`). On click, calls `markAsShipped(fulfillmentId)`. Sets `shipped_at` timestamp.","+  - \"Mark as delivered\" button (shown when fulfillment status is `shipped`). On click, calls `markAsDelivered(fulfillmentId)`. Sets `delivered_at` timestamp. When all fulfillments are delivered, the order's `fulfillment_status` transitions to `fulfilled`.","+"," ### Right Column Sections"," "," #### Customer Card"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"b937fbce-e4c2-47bc-a161-db10c7d70111"}
{"parentUuid":"273b0558-f6da-4782-8530-4905134c6268","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011kCnYkvCFEZMc2SuFqmCLU","type":"message","role":"assistant","content":[{"type":"text","text":"Task #4 done. Now Task #5 (04-STOREFRONT-UI.md)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":549,"cache_read_input_tokens":119264,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":549},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WYwiexsNHmp1gumNZ6","type":"assistant","uuid":"d482d754-3acc-4602-a8bd-d4c95dd861dc","timestamp":"2026-02-11T15:22:21.272Z"}
{"parentUuid":"d482d754-3acc-4602-a8bd-d4c95dd861dc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011kCnYkvCFEZMc2SuFqmCLU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E9KEDBywKkcGmFcQcSgpem","name":"TaskUpdate","input":{"taskId":"4","status":"completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":549,"cache_read_input_tokens":119264,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":549},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WYwiexsNHmp1gumNZ6","type":"assistant","uuid":"6c6c5dc6-834c-4f20-9648-a240d39694d6","timestamp":"2026-02-11T15:22:21.712Z"}
{"parentUuid":"6c6c5dc6-834c-4f20-9648-a240d39694d6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E9KEDBywKkcGmFcQcSgpem","type":"tool_result","content":"Updated task #4 status"}]},"uuid":"8fea3c00-b3cd-40de-8292-c4acd333a667","timestamp":"2026-02-11T15:22:21.720Z","toolUseResult":{"success":true,"taskId":"4","updatedFields":["status"],"statusChange":{"from":"in_progress","to":"completed"}},"sourceToolAssistantUUID":"6c6c5dc6-834c-4f20-9648-a240d39694d6"}
{"parentUuid":"8fea3c00-b3cd-40de-8292-c4acd333a667","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011kCnYkvCFEZMc2SuFqmCLU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01R6H6QnQynYbsavd6eR5cCW","name":"TaskUpdate","input":{"taskId":"5","status":"in_progress"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":549,"cache_read_input_tokens":119264,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":549},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WYwiexsNHmp1gumNZ6","type":"assistant","uuid":"525e430a-797b-4e14-82da-19da6981eaaa","timestamp":"2026-02-11T15:22:22.023Z"}
{"parentUuid":"525e430a-797b-4e14-82da-19da6981eaaa","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01R6H6QnQynYbsavd6eR5cCW","type":"tool_result","content":"Updated task #5 status"}]},"uuid":"b7abe4b4-d0b7-44a3-b2f6-1cb3505f75d8","timestamp":"2026-02-11T15:22:22.026Z","toolUseResult":{"success":true,"taskId":"5","updatedFields":["status"],"statusChange":{"from":"pending","to":"in_progress"}},"sourceToolAssistantUUID":"525e430a-797b-4e14-82da-19da6981eaaa"}
{"type":"file-history-snapshot","messageId":"0bbfc3eb-f0ad-489f-8c98-745c8bafe4a6","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v1","version":1,"backupTime":"2026-02-11T15:14:23.948Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v1","version":1,"backupTime":"2026-02-11T15:16:45.432Z"},"specs/03-ADMIN-UI.md":{"backupFileName":"a791bee027d52553@v1","version":1,"backupTime":"2026-02-11T15:21:51.319Z"},"specs/04-STOREFRONT-UI.md":{"backupFileName":"401411061d8a6ac1@v1","version":1,"backupTime":"2026-02-11T15:22:26.455Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"b7abe4b4-d0b7-44a3-b2f6-1cb3505f75d8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01HUs5oAkqUm5aKu1QrNDJmw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DLQSMNxJXFiLmfF4pizTWj","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","old_string":"The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.","new_string":"The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":221,"cache_read_input_tokens":119813,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":221},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WZCuiFcYEWE6ZpRi34","type":"assistant","uuid":"0bbfc3eb-f0ad-489f-8c98-745c8bafe4a6","timestamp":"2026-02-11T15:22:26.452Z"}
{"parentUuid":"0bbfc3eb-f0ad-489f-8c98-745c8bafe4a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01DLQSMNxJXFiLmfF4pizTWj","toolUseID":"toolu_01DLQSMNxJXFiLmfF4pizTWj","timestamp":"2026-02-11T15:22:26.461Z","uuid":"48c8d594-6c0d-4b64-8204-c81508538ff4"}
{"parentUuid":"0bbfc3eb-f0ad-489f-8c98-745c8bafe4a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DLQSMNxJXFiLmfF4pizTWj","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md has been updated successfully."}]},"uuid":"dbeaa5b8-b876-4323-829b-a76cd341ff7a","timestamp":"2026-02-11T15:22:26.461Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","oldString":"The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.","newString":"The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.","originalFile":"# 04 - Storefront UI/UX Specification\n\n> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n| Templating | Blade templates with reusable storefront-prefixed components |\n| Accessibility | WCAG 2.2 AA compliance throughout |\n| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n\n---\n\n## Currency Formatting\n\nAll monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n\n| Setting | Value |\n|---------|-------|\n| Decimal separator | `.` (period) |\n| Thousands separator | `,` (comma) |\n| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n| Decimal places | Always 2 |\n| Free amounts | Display as `0.00 EUR` |\n| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n\n**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n\n**Examples:**\n- 2499 cents -> `24.99 EUR`\n- 0 cents -> `0.00 EUR`\n- 149900 cents -> `1,499.00 EUR`\n\n---\n\n## 1. Theme System Architecture\n\n### 1.1 View Organization\n\nThe storefront views are organized into the following categories:\n\n| Category | Purpose | Views |\n|---|---|---|\n| Layouts | Master page shell | app (master layout) |\n| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n| Checkout | Purchase flow | checkout index, order confirmation |\n| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n| Errors | Error states | 404, 503 |\n| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n\n### 1.2 Layout Hierarchy\n\nAll storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n\n### 1.3 Theme Settings\n\nTheme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n\n- Announcement bar visibility, text, link, and background color\n- Sticky header toggle\n- Home page section order and visibility\n- Color palette overrides (primary, secondary, accent)\n- Typography scale\n- Footer content and social links\n- Dark mode behavior (system preference, toggle, or forced)\n\n### 1.4 Section-Based Architecture\n\nEach page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n\n| Section | Description | Used On |\n|---|---|---|\n| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n| Featured Collections | Grid of collection cards | Home |\n| Featured Products | Grid of product cards (lazy-loaded) | Home |\n| Newsletter | Email signup form | Home, Footer |\n| Rich Text | Customizable content block | Home, Pages |\n\n### 1.5 Blade Components\n\nAll reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n\n---\n\n## 2. Base Layout\n\n### 2.1 Document Head\n\nThe document head includes:\n\n| Element | Description |\n|---|---|\n| Character encoding | UTF-8 |\n| Viewport | Width = device width, initial scale 1 |\n| Meta description | Page-specific, yielded from child templates |\n| Title | Page-specific title appended with the app name |\n| Stylesheets | Compiled CSS via Vite |\n| Scripts | Compiled JS via Vite |\n| Livewire styles | Injected by Livewire |\n| Language attribute | Set on the HTML element from application locale |\n| Scroll behavior | Smooth scrolling enabled globally |\n\n### 2.2 Skip Link\n\nA visually hidden \"Skip to main content\" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.\n\n**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.\n\n### 2.3 Announcement Bar\n\nOptional, controlled by theme settings. Renders a full-width bar above the header.\n\n| Attribute | Description |\n|---|---|\n| Position | Full-width, above the header |\n| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |\n| Text | Centered, small size, configurable content |\n| Link | Optional, underlined, opens in same tab |\n| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |\n| Height | Auto height with vertical padding |\n\n### 2.4 Header\n\nThe header serves as the primary navigation banner.\n\n**Desktop Layout (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |\n| Main Navigation | Center | Horizontal nav with links sourced from the \"main-menu\" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |\n| Search Icon | Right group | Button that opens the search modal |\n| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |\n| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |\n\n**Mobile Layout (below large breakpoint):**\n\n```\n+--------------------------------------------------------------+\n| [Hamburger]           [Logo]                            [Cart]|\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Hamburger menu | Left | Toggle button for mobile navigation drawer |\n| Logo | Center | Same as desktop but horizontally centered |\n| Cart icon | Right | Same as desktop |\n\n**Sticky Header (optional):**\n\nWhen enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.\n\n**Mobile Navigation Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Animation | Slides in from the left side |\n| Height | Full viewport height |\n| Backdrop | Semi-transparent dark overlay behind the drawer |\n| Close button | X icon at the top-right of the drawer |\n| Navigation items | Vertical stack with comfortable tap targets |\n| Submenus | Collapsible accordion pattern with chevron icons |\n| Account link | Positioned at the bottom of the navigation list |\n| Focus behavior | Focus is trapped within the drawer while open |\n| ARIA | Labeled as \"Mobile navigation\" |\n\n### 2.5 Main Content Area\n\nThe main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.\n\n### 2.6 Footer\n\nThe footer is a content-info landmark.\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n|                                                               |\n| [Social Icons]                                                |\n| [Copyright] [Payment Icons]                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n| Store info column | Store name, address (if configured), contact email |\n| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n| Background | Light gray (dark: very dark gray), top border separator |\n\n### 2.7 Cart Drawer (Livewire)\n\nThe cart drawer is always present in the layout as a global Livewire component. It listens for the \"cart-updated\" browser event to refresh and open. See Section 6 for full specification.\n\n### 2.8 Dark Mode\n\nDark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:\n\n| Mode | Implementation |\n|---|---|\n| System preference (default) | Follows OS-level dark mode preference automatically |\n| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |\n| Forced light/dark | Applied at server render time on the HTML element |\n\n**Color Token Patterns:**\n\n| Token | Light Mode | Dark Mode |\n|---|---|---|\n| Page background | White | Near-black |\n| Card/section background | Light gray | Very dark gray |\n| Heading text | Near-black | White |\n| Body text | Medium gray | Light gray |\n| Borders | Light gray | Medium-dark gray |\n| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |\n\n---\n\n## 3. Home Page (GET /)\n\nThe home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:\n\n1. Hero Banner\n2. Featured Collections\n3. Featured Products\n4. Newsletter Signup\n5. Rich Text\n\n### 3.1 Hero Banner Section\n\n**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.\n\n**Desktop (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n|                                                               |\n|  [Background Image / Gradient]                                |\n|                                                               |\n|       [Heading - very large, bold]                            |\n|       [Subheading - medium, regular weight]                   |\n|       [CTA Button - primary, large]                           |\n|                                                               |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |\n| Content | Centered vertically and horizontally, white text |\n| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |\n| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |\n| CTA | Styled as primary button, large. Links to a configured collection or product |\n| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |\n| Padding | Generous vertical and horizontal padding |\n\n**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.\n\n**Accessibility:**\n\n- If background is an image, it is decorative (the content provides meaning)\n- CTA button has descriptive text (e.g., \"Shop the summer collection\")\n\n### 3.2 Featured Collections Section\n\n**Layout:** Section with heading and grid of collection cards.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, large, bold, centered, with generous bottom margin |\n| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |\n| Count | 2 to 4 collections, configurable in theme settings |\n\n**Collection Card:**\n\n```\n+---------------------------+\n|  [Collection Image]       |\n|  3:4 aspect ratio         |\n|  cover-fit, rounded       |\n|                           |\n|  [Title overlay at bottom]|\n|  [Shop now ->]            |\n+---------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |\n| Title | White text overlaid at bottom of image, semi-bold, medium-large size |\n| \"Shop now\" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |\n| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |\n\n**Accessibility:**\n\n- Each card link has the collection title as accessible name\n- Images have alt text matching the collection title\n\n### 3.3 Featured Products Section\n\n**Livewire Component:** FeaturedProducts\n\nThis section uses Livewire for lazy loading to improve initial page load.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, same styling as featured collections heading |\n| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |\n| Products | 4 to 8, configurable. Sourced from a configured collection or \"best sellers\" logic |\n| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |\n\nEach product uses the product-card component (see Section 16).\n\n### 3.4 Newsletter Signup Section\n\n**Livewire Component:** NewsletterSignup\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n|  [Heading: \"Stay in the loop\"]                                |\n|  [Subtext: \"Subscribe for exclusive offers and updates.\"]     |\n|  [Email input] [Subscribe button]                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Light gray (dark mode: darker gray), generous vertical padding |\n| Content | Centered, constrained to a comfortable width |\n| Heading | H2, medium-large, bold |\n| Subtext | Muted color, small top margin |\n| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |\n| Input | Email type, placeholder \"Enter your email\", required |\n| Button | Primary style, \"Subscribe\" text |\n| Success state | Replaces form with \"Thanks for subscribing!\" message |\n| Error state | Inline error below input in red, small text |\n| Loading state | Button text changes to \"Subscribing...\" during submission |\n\n**Accessibility:**\n\n- Label associated with email input (visually hidden if using placeholder)\n- Success/error messages in a polite live region for screen readers\n\n### 3.5 Rich Text Section\n\nSimple section rendering customizable content from theme settings.\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained to comfortable reading width, centered, vertical padding |\n| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |\n| Dark mode | Inverted prose styling |\n\n---\n\n## 4. Collection Page (GET /collections/{handle})\n\n**Livewire Component:** CollectionPage\n\n### 4.1 Collection Header\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > Collections > {Collection Title} |\n| Title | H1, large, bold |\n| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |\n| Spacing | Generous vertical padding |\n\n### 4.2 Toolbar\n\nHorizontal bar between the header and the product grid.\n\n**Desktop:**\n\n```\n+--------------------------------------------------------------+\n| [Filter toggle button]          [X products] [Sort dropdown]  |\n+--------------------------------------------------------------+\n```\n\n**Mobile:**\n\n```\n+--------------------------------------------------------------+\n| [Filter button]                           [Sort dropdown]     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Product count | Small muted text, e.g., \"24 products\" |\n| Sort dropdown | Select element with live Livewire binding |\n\n**Sort Options:**\n\n| Option | Description |\n|---|---|\n| Featured | Default sort order |\n| Price: Low to High | Ascending by price |\n| Price: High to Low | Descending by price |\n| Newest | Descending by creation date |\n| Best Selling | Descending by sales count |\n\n### 4.3 Filter Sidebar\n\n**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.\n**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by \"Filter\" button with filter icon.\n\n**Filter Groups:**\n\nEach filter group is a collapsible section with a heading and toggle chevron.\n\n| Filter Group | Type | Binding | Description |\n|---|---|---|---|\n| Availability | Single checkbox | Live | \"In stock\" toggle |\n| Price Range | Two number inputs | Live with 500ms debounce | \"Min\" and \"Max\" inputs with currency symbol prefix, displayed side by side |\n| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |\n| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |\n\n**Clear All:**\n\n- Link at the top of the filter sidebar: \"Clear all filters\"\n- Only visible when at least one filter is active\n- Small, blue, interactive text\n\n**Active Filter Pills:**\n\n- Displayed above the product grid (below toolbar) when filters are active\n- Each pill: rounded tag with filter value text and a remove (X) button\n- \"Clear all\" link at the end of the pills row\n\n**Mobile Filter Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Position | Full-height overlay from left side |\n| Header | \"Filters\" title + close button (X) |\n| Content | Same filter groups as desktop, stacked vertically |\n| Footer | Sticky \"Apply\" button at bottom that closes the drawer |\n| Clear | \"Clear all\" link in header area |\n| Focus | Trapped within drawer while open |\n\n### 4.4 Product Grid\n\n| Attribute | Description |\n|---|---|\n| Layout | Fills remaining width next to the sidebar |\n| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |\n| Loading state | Subtle opacity reduction on the grid while results update |\n\n### 4.5 Product Card (Component)\n\nThe product card is a reusable component used on collection pages, search results, and featured products sections.\n\n```\n+---------------------------+\n|  [Product Image]          |\n|  1:1 aspect ratio         |\n|  cover-fit, rounded       |\n|  (hover: show 2nd image)  |\n|                           |\n|  [Sale badge]  [Sold out] |\n+---------------------------+\n|  [Product Title]          |\n|  [Price]  [Compare price] |\n|  [Quick Add / Options]    |\n+---------------------------+\n```\n\n**Image Behavior:**\n\n| Attribute | Description |\n|---|---|\n| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |\n| Primary image | Cover-fit, fills the container |\n| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |\n| Fallback | If no image, show a centered shopping bag placeholder icon |\n| Loading | Lazy-loaded |\n\n**Badges:**\n\n| Badge | Condition | Position |\n|---|---|---|\n| \"Sale\" | compare_at_amount > price_amount | Top-left of image, stacked vertically |\n| \"Sold out\" | All variants out of stock with deny policy | Top-left of image, stacked vertically |\n\n**Text Content:**\n\n| Element | Description |\n|---|---|\n| Title | H3 (or configurable heading level), small semi-bold text. Truncated to 2 lines |\n| Price | Current price in small semi-bold text. Compare-at price shown with strikethrough if applicable |\n| Quick Add | Single variant: \"Add to cart\" button. Multiple variants: \"Choose options\" link text. Hidden by default on desktop (shown on hover), always visible on mobile |\n\n**Accessibility:**\n\n- Card link wraps the entire card; title serves as the accessible name\n- Badges have screen reader labels (e.g., \"On sale\")\n- Price changes announced via parent live region when filtering changes results\n\n### 4.6 Pagination\n\n| Attribute | Description |\n|---|---|\n| Style | Numbered pagination with previous/next arrow buttons |\n| Current page | Highlighted with primary color background and white text |\n| Other pages | Muted text, hover background |\n| Disabled | Reduced opacity and not-allowed cursor on previous (at first page) or next (at last page) |\n| Truncation | Ellipsis for large page counts |\n| Mobile | Simplified: previous/next only with current page indicator |\n\n### 4.7 Empty State\n\nWhen no products match the current filters:\n\n```\n+--------------------------------------------------------------+\n|  [Search icon - large, muted]                                 |\n|  \"No products found\"                                          |\n|  \"Try adjusting your filters or browse our full collection.\"  |\n|  [Clear filters button]                                       |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Icon | Large, muted search icon |\n| Heading | Medium, semi-bold |\n| Subtext | Muted, descriptive guidance |\n| Button | Secondary style, \"Clear filters\" |\n| Position | Centered in the grid area with generous vertical padding |\n\n---\n\n## 5. Product Page (GET /products/{handle})\n\n**Livewire Component:** ProductPage\n\n### 5.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column, 50/50 split. Left column (images) is sticky while the right column (product info) scrolls.\n\n**Mobile:** Single column, stacked. Images first, then product info.\n\n```\nDesktop:\n+-------------------------------+-------------------------------+\n|  [Image Gallery]              |  [Product Info]               |\n|  (sticky while scrolling)     |  (scrollable)                 |\n+-------------------------------+-------------------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Image Gallery - horizontal scroll]                          |\n+--------------------------------------------------------------+\n|  [Product Info - full width]                                  |\n+--------------------------------------------------------------+\n```\n\nContainer constrained to standard max-width with horizontal padding and vertical spacing.\n\n### 5.2 Image Gallery (Left Column)\n\n**Desktop:**\n\n```\n+-------------------------------+\n|                               |\n|  [Main Image - large]         |\n|  1:1 aspect ratio, rounded    |\n|                               |\n+-------------------------------+\n| [Thumb] [Thumb] [Thumb] ...  |\n+-------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Main image | Square aspect ratio, large rounded corners, cover-fit. Gray placeholder background |\n| Zoom on hover | Optional (theme setting): image scales to 150% and follows cursor position |\n| Thumbnail strip | Horizontal row with overflow scroll, small gap between thumbnails |\n| Each thumbnail | 64x64px, rounded, cover-fit, pointer cursor. Active thumbnail has a colored border |\n| Variant images | When a variant is selected that has specific images, those images are shown |\n\n**Mobile:**\n\n| Attribute | Description |\n|---|---|\n| Gallery | Horizontal scrollable row with snap-scroll behavior |\n| Each image | Full-width, square aspect ratio, rounded, cover-fit |\n| Scroll indicators | Row of dots below the gallery. Active dot is dark (light in dark mode), inactive dots are gray |\n\n**Accessibility:**\n\n| Requirement | Description |\n|---|---|\n| Alt text | Main image has descriptive alt text from product media data |\n| Thumbnails | Rendered as buttons with labels \"View image X of Y\" and current state indicator |\n| Region | Gallery wrapped in a labeled region (\"Product images\") |\n\n### 5.3 Product Info (Right Column)\n\n#### Breadcrumbs\n\nHome > {Primary Collection} > {Product Title}. Small, muted text with separator characters.\n\n#### Product Title\n\nH1, large, bold.\n\n#### Price Display\n\n| Element | Description |\n|---|---|\n| Current price | Large, bold |\n| Compare-at price | Medium, muted, strikethrough (shown when set and higher than current price) |\n| Sale badge | Inline badge next to the price |\n| Currency | Formatted based on the store's default currency |\n| Live updates | Price container is a live region, so price changes when variant changes are announced to screen readers |\n\n#### Variant Selector\n\nFor each product option (e.g., Size, Color), render a selector group.\n\n**Radio button style (for options with 6 or fewer values):**\n\n```\nSize\n[S] [M] [L] [XL] [XXL]\n```\n\n| State | Visual Description |\n|---|---|\n| Available | Bordered pill shape, pointer cursor, border darkens on hover |\n| Selected | Primary color border with subtle background tint and focus ring |\n| Unavailable | Muted border, muted text, strikethrough, reduced opacity, not-allowed cursor |\n\n**Select dropdown style:** Used when the option has more than 6 values.\n\n**Color swatches (when option name is \"Color\"):**\n\n| Attribute | Description |\n|---|---|\n| Shape | Circular, 32x32px |\n| Color | Determined by value name mapped to a color lookup or inline style |\n| Selected state | Ring with offset around the swatch |\n| Tooltip | Shows color name on hover |\n\n**Livewire behavior:**\n\n- Selected options are a key-value map (e.g., Size = M, Color = Red)\n- Selected variant is computed by matching all selected options to the variant option values\n- On change: updates price, stock messaging, available images, and add-to-cart availability\n\n#### Stock Messaging\n\nDisplayed below the variant selector.\n\n| Condition | Message | Visual Style |\n|---|---|---|\n| In stock, quantity > 10 | \"In stock\" | Green text with check icon |\n| In stock, quantity <= 10 | \"Only X left in stock\" | Amber text with warning icon |\n| Out of stock, deny policy | \"Out of stock\" | Red text with X icon |\n| Out of stock, continue policy | \"Available on backorder\" | Blue text with info icon |\n\nSmall text, icon + text inline. Wrapped in a live region for screen reader announcements on variant change.\n\n#### Quantity Selector\n\nUses the quantity-selector component.\n\n```\n[-] [3] [+]\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Inline flex, bordered, rounded |\n| Decrease button | \"-\" icon, 40x40px. Disabled at minimum (1) |\n| Input | Centered number input, 56px wide, 40px tall. Min = 1, Max = available stock (deny policy) or unlimited (continue policy) |\n| Increase button | \"+\" icon, same size. Disabled at maximum |\n| Disabled state | Reduced opacity, not-allowed cursor |\n| Labels | Input labeled \"Quantity\", buttons labeled \"Decrease quantity\" / \"Increase quantity\" |\n\n#### Add to Cart Button\n\n| Attribute | Description |\n|---|---|\n| Width | Full width |\n| Style | Primary button: blue background, white text, semi-bold, generous padding, rounded |\n| Text | \"Add to cart\" |\n| Loading state | Text changes to \"Adding...\" with spinner icon. Button disabled during loading |\n| Sold out state | Gray background, muted text, not-allowed cursor. Text: \"Sold out\" |\n| Spacing | Generous top margin |\n\n**Livewire action (addToCart):**\n\n1. Validates variant is selected and in stock\n2. Adds to cart (creates cart if none exists in session)\n3. Dispatches \"cart-updated\" browser event\n4. Cart drawer opens automatically\n\n#### Product Description\n\n- Separated from above content by a horizontal rule with generous vertical spacing\n- Rendered HTML styled with prose/typography styling\n\n#### Tags\n\n- Displayed below the description (if tags exist)\n- Horizontal wrapping row with gap\n- Each tag: rounded pill, gray background, small text\n\n### 5.4 Accessibility Summary\n\n| Requirement | Implementation |\n|---|---|\n| Variant radio buttons | Properly labeled with labels and grouped with fieldset/legend |\n| Live regions | Price display, stock messaging, add-to-cart feedback |\n| Image alt text | All images have meaningful alt text |\n| Keyboard | Tab through thumbnails, variant options, quantity controls, and add-to-cart |\n| Focus indicators | Visible ring styles on all interactive elements |\n\n---\n\n## 6. Cart Drawer\n\n**Livewire Component:** CartDrawer\n\n### 6.1 Trigger and Animation\n\n| Attribute | Description |\n|---|---|\n| Trigger | \"cart-updated\" browser event (from add-to-cart actions), or clicking the cart icon in the header |\n| Animation | Slides in from the right side of the viewport. Transforms from fully off-screen to visible |\n| Backdrop | Fixed full-screen semi-transparent dark overlay. Click to close |\n| Panel | Fixed, right-aligned, full height, full width on small screens or 384px on larger screens. White background (dark mode: near-black), shadow |\n| Body | Scroll locked when drawer is open |\n\n### 6.2 Drawer Header\n\n```\n+--------------------------------------+\n| Your Cart (3)                    [X] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Heading | H2, semi-bold, medium-large text |\n| Item count | In parentheses after heading |\n| Close button | X icon, labeled \"Close cart\" |\n| Layout | Flex row, space-between, with horizontal padding and bottom border |\n\n### 6.3 Cart Line Items\n\nScrollable list area occupying the space between header and totals.\n\nEach line item:\n\n```\n+--------------------------------------+\n| [Thumb]  Title                       |\n|          Variant: Size M / Blue      |\n|          [-] [2] [+]       $49.98    |\n|                             [Remove] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Thumbnail | 64x64px, rounded, cover-fit, non-shrinking |\n| Title | Small semi-bold text, truncated to 1 line |\n| Variant info | Extra-small muted text showing selected options |\n| Quantity stepper | Compact version of quantity-selector (smaller buttons, 32x32px) |\n| Line total | Small semi-bold text |\n| Remove button | Trash icon, muted color, turns red on hover. Labeled \"Remove {item title} from cart\" |\n| Separation | Bottom border between items, vertical padding per item |\n| Loading | Opacity reduction on the line item during quantity update |\n\n### 6.4 Discount Code Section\n\nBelow the line items list, above totals.\n\n**No discount applied:**\n\n```\n+--------------------------------------+\n| [Discount code input] [Apply]        |\n+--------------------------------------+\n```\n\n**Discount applied:**\n\n```\n+--------------------------------------+\n| SUMMER20 (-15%)              [Remove]|\n+--------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Input | Small text, placeholder \"Discount code\" |\n| Apply button | Secondary style, small |\n| Success state | Green text showing the code and discount type, with a remove button |\n| Error state | Small red text below, e.g., \"Invalid discount code\" or \"This code has expired\" |\n| Loading state | Button text changes to \"Applying...\" |\n\n### 6.5 Totals Section\n\n```\n+--------------------------------------+\n| Subtotal                     $149.97 |\n| Discount (SUMMER20)          -$22.50 |\n| ------------------------------------ |\n| Estimated total              $127.47 |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Position | Sticky at the bottom of the drawer, with top border |\n| Each line | Flex row, justified space-between, small text |\n| Discount line | Only shown when discount applied, green text |\n| Total | Slightly larger text, semi-bold |\n| Note | \"Shipping and taxes calculated at checkout\" in extra-small muted text below totals |\n\n### 6.6 Action Buttons\n\n| Button | Description |\n|---|---|\n| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |\n| Continue shopping | Centered text link below checkout button. Small muted text, hover to darker. Closes the drawer |\n\n### 6.7 Empty Cart State\n\n```\n+--------------------------------------+\n|                                      |\n|  [Shopping bag icon - large]         |\n|  \"Your cart is empty\"                |\n|  [Continue shopping button]          |\n|                                      |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Centered vertically and horizontally in the drawer content area |\n| Icon | Large, very muted shopping bag icon |\n| Text | Muted, with top margin |\n| Button | Secondary style, closes the drawer |\n\n### 6.8 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Role | Dialog with modal behavior, labeled \"Shopping cart\" |\n| Focus trap | Tab/Shift+Tab cycles through drawer elements only |\n| On open | Focus moves to the close button or first interactive element |\n| On close | Focus returns to the element that triggered the drawer (cart icon) |\n| Escape key | Closes the drawer |\n| Feedback | All quantity changes and removals announced via polite live region |\n\n---\n\n## 7. Full Cart Page (GET /cart)\n\n**Livewire Component:** CartPage\n\nSame functionality as the cart drawer but in a full-page layout.\n\n### 7.1 Desktop Layout\n\n```\n+--------------------------------------------------------------+\n|  Your Cart                                                    |\n+--------------------------------------------------------------+\n|  Image | Product      | Price   | Quantity | Total   |       |\n|  ------|------------- |---------|----------|---------|-------|\n|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n|        | Size: M      |         |          |         |       |\n|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n|        | Color: Blue  |         |          |         |       |\n+--------------------------------------------------------------+\n|                                            Subtotal: $84.97   |\n|                              [Discount code] [Apply]          |\n|                                            Discount: -$12.75  |\n|                                            Total:    $72.22   |\n|                                                               |\n|                                            [Checkout button]  |\n|                                            [Continue shopping]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n| Title | H1, large bold, generous bottom margin |\n| Desktop layout | Proper table with header row and body |\n| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n\n### 7.2 Totals and Actions\n\n- Right-aligned on desktop, full-width on mobile\n- Same discount code input/apply functionality as drawer\n- Same totals breakdown\n- \"Checkout\" button: primary, aligned right\n- \"Continue shopping\" link: below checkout button\n\n### 7.3 Empty State\n\nSame as drawer empty state but centered in the page content area with more vertical padding.\n\n---\n\n## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment\n\n| Element | Description |\n|---|---|\n| Order review | Brief summary of items, address, and shipping method |\n| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.\n\n```\n+--------------------------------------------------------------+\n|  [Checkmark icon - green circle]                              |\n|  \"Thank you for your order!\"                                  |\n|  Order #1042                                                  |\n|  \"We've sent a confirmation to you@email.com\"                 |\n+--------------------------------------------------------------+\n|  Order Summary                                                |\n|  [Items list with thumbnails]                                 |\n+--------------------------------------------------------------+\n|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Visa ending in 4242             |\n|  123 Main St               |                                  |\n|  New York, NY 10001        |                                  |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  [Continue shopping button]  [View order - if logged in]      |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained to a comfortable width, centered, generous vertical padding |\n| Success icon | Large green checkmark in a circle, centered |\n| Heading | H1, large bold, centered, generous top margin |\n| Order number | Medium muted text, centered |\n| Email notice | Small muted text, centered |\n\n### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Totals | Right-aligned summary |\n\n### 9.3 Actions\n\n| Button | Description |\n|---|---|\n| Continue shopping | Primary, links to home page |\n| View order | Link to account order detail page (logged-in customers only) |\n| Layout | Centered row with gap |\n\n---\n\n## 10. Customer Account Pages\n\nAll account pages require authentication. Unauthenticated access redirects to the login page.\n\n### 10.1 Login (GET /account/login)\n\n**Livewire Component:** Login\n\n```\n+--------------------------------------------------------------+\n|  Log in to your account                                       |\n|                                                               |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Forgot password? link]                                      |\n|  [Log in button]                                              |\n|                                                               |\n|  Don't have an account? [Create one]                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, autocomplete for current password |\n| Forgot password | Small blue link, right-aligned |\n| Submit button | Primary, full-width, \"Log in\" |\n| Error messages | Inline below fields in small red text |\n| General error | Invalid credentials shown in a callout/alert above the form |\n| Register link | \"Create one\" link to registration page |\n\n### 10.2 Register (GET /account/register)\n\n**Livewire Component:** Register\n\n```\n+--------------------------------------------------------------+\n|  Create an account                                            |\n|                                                               |\n|  [Name input]                                                 |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Confirm password input]                                     |\n|  [ ] Subscribe to marketing emails                            |\n|  [Create account button]                                      |\n|                                                               |\n|  Already have an account? [Log in]                            |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Name | Text type, required, autocomplete for name |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, minimum 8 characters, autocomplete for new password |\n| Confirm password | Password type, required, autocomplete for new password |\n| Marketing checkbox | Checkbox with label \"Subscribe to marketing emails\" |\n| Submit button | Primary, full-width, \"Create account\" |\n| On success | Redirect to account dashboard |\n\n### 10.3 Account Dashboard (GET /account)\n\n```\n+--------------------------------------------------------------+\n|  Welcome back, Jane!                                          |\n+--------------------------------------------------------------+\n|  [Orders card]     [Addresses card]     [Log out card]        |\n+--------------------------------------------------------------+\n|  Recent Orders                                                |\n|  # | Date       | Status  | Total   | Action                 |\n|  --|------------|---------|---------|--------                 |\n|  1042 | Jan 15  | Paid    | $98.70  | View                   |\n|  1041 | Jan 10  | Shipped | $45.00  | View                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained max-width, centered, with padding |\n| Welcome heading | H1, medium-large bold |\n| Quick links | Responsive grid: 1 column on mobile, 3 on tablet+. Each card has border, rounded corners, padding, hover shadow |\n| Orders card | Order icon, \"Order history\", \"View all your orders\" |\n| Addresses card | Location icon, \"Addresses\", \"Manage your addresses\" |\n| Log out card | Logout icon, \"Log out\", submits POST logout form |\n| Recent orders | Table showing last 5 orders. Status shown as badge component |\n\n### 10.4 Order History (GET /account/orders)\n\n| Attribute | Description |\n|---|---|\n| Heading | H1, \"Order History\" |\n| Table columns | Order (link), Date, Status (badge), Total, Action (\"View\" link) |\n| Status badge colors | Pending: yellow. Paid: green. Fulfilled: blue. Cancelled: gray. Refunded: red |\n| Mobile | Card-based layout instead of table. Each card shows order number, date, status, and total |\n| Pagination | Standard pagination component |\n\n### 10.5 Order Detail (GET /account/orders/{orderNumber})\n\n```\n+--------------------------------------------------------------+\n|  Order #1042                      Status: [Paid] [Fulfilled] |\n|  Placed on January 15, 2026                                  |\n+--------------------------------------------------------------+\n|  Items                                                        |\n|  [Thumb] Product Title x2              $49.98                 |\n|          Size: M                                              |\n+--------------------------------------------------------------+\n|  Shipping Address    |  Billing Address   | Payment           |\n|  Jane Doe            |  Same as shipping  | Visa ****4242     |\n|  123 Main St         |                    |                   |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Discount        -$0.00                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  Fulfillment                                                  |\n|  Shipped via UPS - 1Z999AA10123456784                         |\n|  [Track shipment ->]                                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Account > Orders > #1042 |\n| Header | Order number and status badges |\n| Date | Small muted text |\n| Items | Table with thumbnail, title + variant info, quantity, line total |\n| Info grid | Three columns on desktop (shipping, billing, payment), stacked on mobile |\n| Totals | Summary breakdown |\n| Fulfillment | Only shown if fulfillments exist. Shows tracking company, number, and link to tracking URL (opens in new tab safely) |\n\n### 10.6 Address Book (GET /account/addresses)\n\n**Livewire Component:** AddressBook\n\n```\n+--------------------------------------------------------------+\n|  Your Addresses                          [+ Add new address]  |\n+--------------------------------------------------------------+\n|  [Default badge]              |                               |\n|  Jane Doe                     |  Jane Doe                     |\n|  123 Main St                  |  456 Oak Ave                  |\n|  New York, NY 10001           |  Chicago, IL 60601            |\n|  United States                |  United States                |\n|  [Edit] [Delete]              |  [Edit] [Delete] [Set default]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Grid | 1 column on mobile, 2 on tablet, 3 on desktop |\n| Each card | Bordered, rounded, with padding. Default address has a primary-colored border and \"Default\" badge |\n| Content | Formatted address lines |\n| Actions | Edit and Delete text buttons, with \"Set as default\" on non-default addresses |\n| Add new | Opens modal with address-form component |\n| Edit | Opens same modal pre-filled |\n| Delete | Confirmation dialog before deleting |\n\n---\n\n## 11. Search\n\n### 11.1 Search Modal\n\n**Livewire Component:** SearchModal\n\nTriggered by clicking the search icon in the header.\n\n```\n+--------------------------------------------------------------+\n|  [Search input with icon]                           [X close] |\n+--------------------------------------------------------------+\n|  Products                                                     |\n|  [Thumb] Product Title            $24.99                      |\n|  [Thumb] Product Title            $34.99                      |\n|                                                               |\n|  Collections                                                  |\n|  Summer Collection                                            |\n|  Winter Collection                                            |\n|                                                               |\n|  [View all X results ->]                                      |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Overlay | Full-screen semi-transparent dark backdrop |\n| Modal | Centered, moderate max-width, rounded corners, shadow |\n| Search input | Large text, search icon prefix, autofocus on open. Live binding with 300ms debounce |\n| Results | Appear below input as user types. Grouped by type: \"Products\" and \"Collections\" headings |\n| Product results | Thumbnail, title, price. Clickable to product page |\n| Collection results | Title only. Clickable to collection page |\n| Max results | 5 per category in the modal |\n| View all | Link at the bottom navigating to the full search results page |\n| Loading | Skeleton lines while fetching |\n| No results | \"No results for '{query}'\" message |\n| Keyboard | Escape closes, arrow keys navigate results, Enter selects highlighted result |\n| Focus | Trapped within the modal |\n| ARIA | Dialog role, modal, labeled \"Search\". Results list as listbox with option items |\n\n### 11.2 Search Results Page (GET /search?q=)\n\n**Livewire Component:** SearchResults\n\nSame layout as the collection page (Section 4) with the following differences:\n\n| Difference | Description |\n|---|---|\n| Heading | H1, \"X results for '{query}'\" or \"Search results for '{query}'\" |\n| Breadcrumbs | Home > Search results |\n| Filters | Same filter sidebar (availability, price, type, vendor) |\n| Sort | Same sort dropdown |\n| Product grid | Same product card component |\n| Pagination | Same pagination |\n| Empty state | \"No results found for '{query}'. Try a different search term.\" with a search input to try again |\n\n---\n\n## 12. Content Pages (GET /pages/{handle})\n\n### 12.1 Layout\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > {Page Title} |\n| Title | H1, large bold |\n| Body | Rendered HTML from body_html, styled with prose/typography styling, constrained to comfortable reading width (configurable to full width via theme settings) |\n| Container | Standard max-width, centered, with padding |\n\n---\n\n## 13. Error Pages\n\n### 13.1 404 Page\n\n```\n+--------------------------------------------------------------+\n|  [404 - large, bold, very muted]                              |\n|  \"Page not found\"                                             |\n|  \"The page you're looking for doesn't exist or has been       |\n|   moved.\"                                                     |\n|                                                               |\n|  [Search input]                                               |\n|  [Go to home page button]                                     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered vertically and horizontally |\n| 404 text | Very large, bold, extremely muted (barely visible as a background element) |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Search | Simple input + button, submits to search page |\n| Home link | Secondary button |\n\n### 13.2 503 / Maintenance Page\n\n```\n+--------------------------------------------------------------+\n|  [Store logo]                                                 |\n|  \"We'll be back soon\"                                         |\n|  \"We're currently performing maintenance. Please check back   |\n|   shortly.\"                                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered column |\n| Logo | Standard height, centered |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Style | Consistent with the store theme (colors, fonts) |\n\n---\n\n## 14. Responsive Breakpoints\n\nDefault breakpoints used across the storefront:\n\n| Breakpoint | Min-width | Typical Devices |\n|---|---|---|\n| Base (none) | 0px | Mobile phones (portrait) |\n| Small | 640px | Mobile phones (landscape), small tablets |\n| Medium | 768px | Tablets (portrait) |\n| Large | 1024px | Tablets (landscape), small desktops |\n| Extra-large | 1280px | Desktops |\n\n### Key Responsive Behaviors\n\n| Component | Mobile (base) | Tablet (medium) | Desktop (large+) |\n|---|---|---|---|\n| Navigation | Hamburger + drawer | Hamburger + drawer | Horizontal nav bar |\n| Product grid | 2 columns | 3 columns | 3-4 columns |\n| Product page | Stacked (images, then info) | Stacked | Side-by-side 50/50 |\n| Cart drawer | Full-screen overlay | 384px sidebar | 384px sidebar |\n| Checkout | Single column, summary collapsed | Single column | Two columns (form + summary) |\n| Footer | Stacked columns | 2-column grid | 3-4 column grid |\n| Collection filters | Drawer overlay | Drawer overlay | Persistent sidebar |\n| Account tables | Card-based layout | Card-based layout | Table layout |\n\n### Touch Considerations\n\n| Consideration | Requirement |\n|---|---|\n| Tap targets | Minimum 44x44px for all interactive elements on mobile |\n| Hover effects | Only applied on devices with hover capability |\n| Swipe gestures | Horizontal swipe on product images (mobile gallery), cart drawer close |\n\n---\n\n## 15. Accessibility Checklist\n\n### Global (All Pages)\n\n| Requirement | Description |\n|---|---|\n| Skip link | \"Skip to main content\" link, first element in body |\n| Landmark roles | Banner for header, main for content, contentinfo for footer, labeled nav elements |\n| Heading hierarchy | Single H1 per page, logical H2-H6 nesting |\n| Focus indicators | Visible ring on all interactive elements with offset |\n| Color contrast | Minimum 4.5:1 for normal text, 3:1 for large text (WCAG AA) |\n| Keyboard navigation | All interactive elements reachable via Tab, operable via Enter/Space |\n| Screen reader text | Visually hidden text for icon-only buttons (close, cart, search) |\n| Language | Lang attribute on HTML element |\n| Images | All images have alt attributes; decorative images marked appropriately |\n\n### Page-Specific Accessibility\n\n| Page | Requirements |\n|---|---|\n| Collection Page | Live region for product count/grid on filter change. Filter sidebar labeled as navigation. Sort dropdown has associated label |\n| Product Page | Variant selector uses fieldset/legend grouping. Price and stock in live regions. Image gallery thumbnails as buttons with current state |\n| Cart Drawer | Dialog role with modal behavior, labeled \"Shopping cart\". Focus trap. Quantity changes in live region |\n| Checkout | Form validation with describedby linking to errors, invalid state on fields. Focus management on step transitions. Shipping radios in fieldset/legend. Totals in live region |\n| Search Modal | Dialog role with modal behavior. Results as listbox with option items. Active descendant tracking for keyboard navigation |\n| Account Pages | All form inputs labeled. Tables use proper header markup with column scope. Status badges readable by text (not color-only) |\n\n### Keyboard Interactions\n\n| Component | Key | Action |\n|---|---|---|\n| Hamburger menu | Enter/Space | Toggle open/close |\n| Mobile nav drawer | Escape | Close |\n| Dropdown menus | Arrow Up/Down | Navigate items |\n| Dropdown menus | Escape | Close |\n| Cart drawer | Escape | Close |\n| Search modal | Escape | Close |\n| Search results | Arrow Up/Down | Navigate results |\n| Search results | Enter | Select highlighted result |\n| Variant radio buttons | Arrow Left/Right | Navigate options |\n| Quantity input | Arrow Up/Down | Increment/decrement |\n| Modals/dialogs | Tab/Shift+Tab | Cycle focus within (trap) |\n\n---\n\n## 16. Component Library\n\n### storefront-layout\n\nMaster layout wrapper.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| title | string | App name from config | Page title |\n| metaDescription | string | Empty | Meta description tag |\n\n### storefront-product-card\n\nRenders a single product card in grid contexts.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| product | Product model | Required | Product with variants, media, and inventory eager-loaded |\n| headingLevel | string | h3 | Heading tag for the product title |\n| showQuickAdd | boolean | true | Whether to show the quick add / choose options action |\n\n**Eager-load requirements:** product.variants.inventoryItem, product.media (sorted by position).\n\n### storefront-price\n\nDisplays a formatted price with optional compare-at price.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| amount | integer | Required | Price in minor units (cents) |\n| currency | string | USD | ISO 4217 currency code |\n| compareAtAmount | integer or null | null | Original price in minor units for sale display |\n\n**Rendering:** Formats amount using Laravel's Number currency helper. If compareAtAmount is set and greater than amount, shows compare-at price with strikethrough styling and a \"Sale\" badge.\n\n### storefront-badge\n\nRenders a styled badge/tag.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| text | string | Required | Badge text |\n| variant | string | default | Visual variant: sale, sold-out, new, default |\n\n**Variant Styles:**\n\n| Variant | Background | Text Color |\n|---|---|---|\n| sale | Light red (dark: dark red tint) | Red |\n| sold-out | Light gray (dark: dark gray) | Medium gray |\n| new | Light blue (dark: dark blue tint) | Blue |\n| default | Light gray (dark: dark gray) | Dark gray |\n\n**Base appearance:** Inline pill shape, rounded-full, extra-small medium-weight text with horizontal padding.\n\n### storefront-quantity-selector\n\nNumber input with increment/decrement buttons.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| value | integer | 1 | Current quantity |\n| min | integer | 1 | Minimum allowed value |\n| max | integer or null | null | Maximum allowed value (null = unlimited) |\n| wireModel | string | Required | Livewire model binding string |\n| compact | boolean | false | Smaller variant for cart drawer |\n\n### storefront-address-form\n\nRenders a full address form with all fields.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| address | array or null | null | Pre-filled address data |\n| prefix | string | Empty | Livewire model prefix (e.g., \"shipping\" for shipping.firstName) |\n\n### storefront-order-summary\n\nRenders the checkout order summary sidebar.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |\n| showDiscountInput | boolean | true | Whether to show the discount code input |\n\n### storefront-breadcrumbs\n\nRenders a breadcrumb navigation trail.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| items | array | Required | Array of objects with label and url. Last item has no URL (current page) |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Breadcrumb\"\n- Ordered list with separator characters between items\n- Current page (last item) has a \"current page\" indicator and distinct styling\n- Includes structured data (BreadcrumbList schema) for SEO\n\n### storefront-pagination\n\nWraps Laravel's pagination with custom styling.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| paginator | LengthAwarePaginator | Required | Laravel paginator instance |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Pagination\"\n- Previous/Next buttons with arrow icons\n- Numbered page buttons with current page highlighted\n- Ellipsis for truncated ranges\n- Mobile: previous/next only with \"Page X of Y\" text\n\n---\n\n## 17. Livewire Component Summary\n\n| Component | Location | Purpose |\n|---|---|---|\n| FeaturedProducts | Home page | Lazy-loaded featured products grid |\n| NewsletterSignup | Home page, footer | Email subscription form |\n| CollectionPage | Collection page | Filtering, sorting, pagination |\n| ProductPage | Product page | Variant selection, add to cart |\n| CartDrawer | Layout (global) | Slide-in cart with line management |\n| CartPage | Cart page | Full-page cart |\n| CheckoutPage | Checkout | Multi-step checkout flow |\n| SearchModal | Layout (global) | Autocomplete search overlay |\n| SearchResults | Search page | Full search results with filters |\n| Login | Login page | Authentication form |\n| Register | Register page | Registration form |\n| AddressBook | Addresses page | CRUD for saved addresses |\n\n---\n\n## 18. Browser Event Communication\n\nLivewire components communicate via dispatched browser events:\n\n| Event Name | Dispatched By | Listened By | Payload |\n|---|---|---|---|\n| cart-updated | ProductPage, CartDrawer, CartPage | CartDrawer (opens + refreshes), Header (badge count) | cartId, itemCount |\n| open-cart-drawer | Header cart icon click | CartDrawer | None |\n| close-cart-drawer | CartDrawer close actions | CartDrawer | None |\n| open-search-modal | Header search icon click | SearchModal | None |\n| close-search-modal | SearchModal close actions | SearchModal | None |\n| variant-changed | ProductPage (variant selector) | ProductPage (internal) | variantId, price, stock |\n\n---\n\n## 19. SEO and Structured Data\n\n### Meta Tags\n\nEach page type sets appropriate meta tags:\n\n| Page | Title Pattern | Description Source |\n|---|---|---|\n| Home | Store name | Theme settings |\n| Collection | \"{Collection title} - {Store name}\" | Collection description (stripped of markup) |\n| Product | \"{Product title} - {Store name}\" | Product description (stripped, truncated to 160 chars) |\n| Content page | \"{Page title} - {Store name}\" | Page body (stripped, truncated) |\n| Search | \"Search results - {Store name}\" | Static |\n\n### Open Graph Tags\n\nProduct pages include Open Graph tags for social sharing:\n\n| Tag | Value |\n|---|---|\n| og:title | Product title |\n| og:description | Meta description |\n| og:image | Primary product image URL |\n| og:type | product |\n| og:url | Current page URL |\n| product:price:amount | Formatted price (dollars) |\n| product:price:currency | Store currency code |\n\n### JSON-LD Structured Data\n\n| Schema Type | Used On |\n|---|---|\n| Product | Product pages |\n| BreadcrumbList | Breadcrumb navigation on all pages |\n| Organization | Footer (store info) |\n\n---\n\n## 20. User Flow Diagrams\n\n### Browse to Purchase Flow\n\n```mermaid\nflowchart TD\n    A[Home Page] --> B{Browse}\n    B --> C[Collection Page]\n    B --> D[Search Modal]\n    D --> E[Search Results Page]\n    C --> F[Product Page]\n    E --> F\n    F --> G{Select Variant}\n    G --> H[Set Quantity]\n    H --> I[Add to Cart]\n    I --> J[Cart Drawer Opens]\n    J --> K{Continue?}\n    K -->|Continue Shopping| B\n    K -->|Apply Discount| L[Enter Discount Code]\n    L --> J\n    K -->|Checkout| M[Checkout Page]\n    M --> N[Step 1: Contact Info]\n    N --> O[Step 2: Shipping Address]\n    O --> P[Step 3: Shipping Method]\n    P --> Q[Step 4: Payment]\n    Q --> R[Stripe/PayPal Redirect]\n    R --> S[Order Confirmation]\n    S --> T{Next?}\n    T -->|Continue Shopping| A\n    T -->|View Order| U[Account Order Detail]\n```\n\n### Account Management Flow\n\n```mermaid\nflowchart TD\n    A[Login Page] --> B{Has Account?}\n    B -->|No| C[Register Page]\n    C --> D[Account Dashboard]\n    B -->|Yes| D\n    D --> E{Action}\n    E --> F[Order History]\n    F --> G[Order Detail]\n    G --> H[Track Shipment]\n    E --> I[Address Book]\n    I --> J{Action}\n    J --> K[Add Address]\n    J --> L[Edit Address]\n    J --> M[Delete Address]\n    J --> N[Set Default Address]\n    E --> O[Log Out]\n```\n\n### Cart Management Flow\n\n```mermaid\nflowchart TD\n    A[Add to Cart] --> B[Cart Drawer Opens]\n    B --> C{Action}\n    C --> D[Update Quantity]\n    D --> B\n    C --> E[Remove Item]\n    E --> F{Cart Empty?}\n    F -->|Yes| G[Empty Cart State]\n    G --> H[Continue Shopping]\n    F -->|No| B\n    C --> I[Apply Discount Code]\n    I --> J{Valid?}\n    J -->|Yes| K[Discount Applied]\n    K --> B\n    J -->|No| L[Error Message]\n    L --> B\n    C --> M[Proceed to Checkout]\n    M --> N[Checkout Page]\n    C --> O[View Full Cart Page]\n    O --> N\n```\n\n### Component State Diagram\n\n```mermaid\nstateDiagram-v2\n    [*] --> Empty: Cart initialized\n\n    state \"Cart Drawer\" as CD {\n        Empty --> HasItems: Add item\n        HasItems --> HasItems: Update quantity\n        HasItems --> HasItems: Apply discount\n        HasItems --> Empty: Remove last item\n        HasItems --> Checkout: Proceed to checkout\n    }\n\n    state \"Product Page\" as PP {\n        NoVariant --> VariantSelected: Select options\n        VariantSelected --> InStock: Stock > 0\n        VariantSelected --> OutOfStock: Stock = 0\n        InStock --> Adding: Click add to cart\n        Adding --> InStock: Complete\n        OutOfStock --> SoldOut: Deny policy\n        OutOfStock --> Backorder: Continue policy\n    }\n\n    state \"Checkout\" as CO {\n        Contact --> ShippingAddress: Complete step 1\n        ShippingAddress --> ShippingMethod: Complete step 2\n        ShippingMethod --> Payment: Complete step 3\n        Payment --> Processing: Submit payment\n        Processing --> Confirmation: Payment success\n        Processing --> Payment: Payment failure\n    }\n```\n\n---\n\n## 21. Flash / Toast Messages\n\n| Action | Message | Type |\n|--------|---------|------|\n| Item added to cart | \"Added to cart\" | success |\n| Item removed from cart | \"Item removed\" | success |\n| Cart is empty | \"Your cart is empty\" | info |\n| Discount applied | \"Discount applied\" | success |\n| Invalid discount code | \"Invalid discount code\" | error |\n| Out of stock | \"This product is currently out of stock\" | warning |\n| Registration success | \"Account created successfully\" | success |\n| Login success | (redirect, no message) | - |\n| Login failed | \"Invalid credentials\" | error |\n| Password reset sent | \"We have emailed your password reset link!\" | success |\n| Order placed | \"Thank you for your order!\" | success |\n","structuredPatch":[{"oldStart":963,"oldLines":7,"newStart":963,"newLines":7,"lines":[" "," ### 8.2 Checkout Steps"," ","-The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment.","+The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay."," "," **Step State Behavior:**"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"0bbfc3eb-f0ad-489f-8c98-745c8bafe4a6"}
{"parentUuid":"dbeaa5b8-b876-4323-829b-a76cd341ff7a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01HUs5oAkqUm5aKu1QrNDJmw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0168mcdAC7CZppJ8aZqGWesk","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","old_string":"#### Step 4 - Payment\n\n| Element | Description |\n|---|---|\n| Order review | Brief summary of items, address, and shipping method |\n| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |","new_string":"#### Step 4 - Payment Method & Pay\n\nAll payment is handled onsite via the Mock PSP. No external redirects, no iframes, no third-party scripts.\n\n**Payment method selection (radio buttons):**\n\n```\n+--------------------------------------------------------------+\n| Select a payment method                                       |\n|                                                               |\n| (o) Credit Card                                               |\n| ( ) PayPal                                                    |\n| ( ) Bank Transfer                                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Layout | Radio button list, each option in a bordered card (same styling as shipping method radios) |\n| Default | First option (Credit Card) is pre-selected |\n| Live binding | On selection, the payment form below updates to show the relevant fields |\n\n**Credit Card form (shown when Credit Card is selected):**\n\n```\n+--------------------------------------------------------------+\n| Card number     [4242 4242 4242 4242        ]                |\n| Cardholder name [John Doe                   ]                |\n| Expiry          [12/28     ]  CVC [123    ]                  |\n+--------------------------------------------------------------+\n| [Pay now - 29.98 EUR]                                        |\n+--------------------------------------------------------------+\n```\n\n| Field | Type | Required | Validation |\n|---|---|---|---|\n| Card number | Text (digits, auto-formatted with spaces every 4 digits) | Yes | 16 digits |\n| Cardholder name | Text | Yes | Max 255 |\n| Expiry | Text (MM/YY format) | Yes | Valid future date |\n| CVC | Text (3-4 digits) | Yes | 3-4 digits |\n\n**PayPal form (shown when PayPal is selected):**\n\n```\n+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Pay with PayPal - {total}\".\n\n**Bank Transfer form (shown when Bank Transfer is selected):**\n\n```\n+--------------------------------------------------------------+\n| After placing your order, you will receive bank transfer      |\n| instructions. Your order will be held for 7 days while we     |\n| await your payment.                                           |\n|                                                               |\n| [Place order - 29.98 EUR]                                     |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Place order - {total}\" (not \"Pay now\", since payment is deferred).\n\n| Element | Description |\n|---|---|\n| Pay/Place button | Primary, full-width, large text. Loading state: \"Processing...\" with spinner. Calls `POST /checkouts/{id}/pay` with the selected method and card details (if credit card). |\n| Error handling | On payment decline (magic card number), display an inline error callout: \"Payment declined: {reason}\". The customer can correct details and retry. |\n| Success (card/paypal) | On successful instant capture, redirect to confirmation page. |\n| Success (bank_transfer) | On successful order placement, redirect to confirmation page (which shows bank transfer instructions). |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":221,"cache_read_input_tokens":119813,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":221},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WZCuiFcYEWE6ZpRi34","type":"assistant","uuid":"4ed70088-f519-4408-bb9c-bdf3d9175d44","timestamp":"2026-02-11T15:22:46.547Z"}
{"parentUuid":"4ed70088-f519-4408-bb9c-bdf3d9175d44","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_0168mcdAC7CZppJ8aZqGWesk","toolUseID":"toolu_0168mcdAC7CZppJ8aZqGWesk","timestamp":"2026-02-11T15:22:46.553Z","uuid":"9d554886-d3af-4d8c-8868-62ed253f5c98"}
{"parentUuid":"4ed70088-f519-4408-bb9c-bdf3d9175d44","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0168mcdAC7CZppJ8aZqGWesk","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md has been updated successfully."}]},"uuid":"3c1033c0-ceb1-4611-86e9-6eaf4cf9e201","timestamp":"2026-02-11T15:22:46.552Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","oldString":"#### Step 4 - Payment\n\n| Element | Description |\n|---|---|\n| Order review | Brief summary of items, address, and shipping method |\n| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |","newString":"#### Step 4 - Payment Method & Pay\n\nAll payment is handled onsite via the Mock PSP. No external redirects, no iframes, no third-party scripts.\n\n**Payment method selection (radio buttons):**\n\n```\n+--------------------------------------------------------------+\n| Select a payment method                                       |\n|                                                               |\n| (o) Credit Card                                               |\n| ( ) PayPal                                                    |\n| ( ) Bank Transfer                                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Layout | Radio button list, each option in a bordered card (same styling as shipping method radios) |\n| Default | First option (Credit Card) is pre-selected |\n| Live binding | On selection, the payment form below updates to show the relevant fields |\n\n**Credit Card form (shown when Credit Card is selected):**\n\n```\n+--------------------------------------------------------------+\n| Card number     [4242 4242 4242 4242        ]                |\n| Cardholder name [John Doe                   ]                |\n| Expiry          [12/28     ]  CVC [123    ]                  |\n+--------------------------------------------------------------+\n| [Pay now - 29.98 EUR]                                        |\n+--------------------------------------------------------------+\n```\n\n| Field | Type | Required | Validation |\n|---|---|---|---|\n| Card number | Text (digits, auto-formatted with spaces every 4 digits) | Yes | 16 digits |\n| Cardholder name | Text | Yes | Max 255 |\n| Expiry | Text (MM/YY format) | Yes | Valid future date |\n| CVC | Text (3-4 digits) | Yes | 3-4 digits |\n\n**PayPal form (shown when PayPal is selected):**\n\n```\n+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Pay with PayPal - {total}\".\n\n**Bank Transfer form (shown when Bank Transfer is selected):**\n\n```\n+--------------------------------------------------------------+\n| After placing your order, you will receive bank transfer      |\n| instructions. Your order will be held for 7 days while we     |\n| await your payment.                                           |\n|                                                               |\n| [Place order - 29.98 EUR]                                     |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Place order - {total}\" (not \"Pay now\", since payment is deferred).\n\n| Element | Description |\n|---|---|\n| Pay/Place button | Primary, full-width, large text. Loading state: \"Processing...\" with spinner. Calls `POST /checkouts/{id}/pay` with the selected method and card details (if credit card). |\n| Error handling | On payment decline (magic card number), display an inline error callout: \"Payment declined: {reason}\". The customer can correct details and retry. |\n| Success (card/paypal) | On successful instant capture, redirect to confirmation page. |\n| Success (bank_transfer) | On successful order placement, redirect to confirmation page (which shows bank transfer instructions). |","originalFile":"# 04 - Storefront UI/UX Specification\n\n> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n| Templating | Blade templates with reusable storefront-prefixed components |\n| Accessibility | WCAG 2.2 AA compliance throughout |\n| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n\n---\n\n## Currency Formatting\n\nAll monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n\n| Setting | Value |\n|---------|-------|\n| Decimal separator | `.` (period) |\n| Thousands separator | `,` (comma) |\n| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n| Decimal places | Always 2 |\n| Free amounts | Display as `0.00 EUR` |\n| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n\n**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n\n**Examples:**\n- 2499 cents -> `24.99 EUR`\n- 0 cents -> `0.00 EUR`\n- 149900 cents -> `1,499.00 EUR`\n\n---\n\n## 1. Theme System Architecture\n\n### 1.1 View Organization\n\nThe storefront views are organized into the following categories:\n\n| Category | Purpose | Views |\n|---|---|---|\n| Layouts | Master page shell | app (master layout) |\n| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n| Checkout | Purchase flow | checkout index, order confirmation |\n| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n| Errors | Error states | 404, 503 |\n| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n\n### 1.2 Layout Hierarchy\n\nAll storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n\n### 1.3 Theme Settings\n\nTheme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n\n- Announcement bar visibility, text, link, and background color\n- Sticky header toggle\n- Home page section order and visibility\n- Color palette overrides (primary, secondary, accent)\n- Typography scale\n- Footer content and social links\n- Dark mode behavior (system preference, toggle, or forced)\n\n### 1.4 Section-Based Architecture\n\nEach page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n\n| Section | Description | Used On |\n|---|---|---|\n| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n| Featured Collections | Grid of collection cards | Home |\n| Featured Products | Grid of product cards (lazy-loaded) | Home |\n| Newsletter | Email signup form | Home, Footer |\n| Rich Text | Customizable content block | Home, Pages |\n\n### 1.5 Blade Components\n\nAll reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n\n---\n\n## 2. Base Layout\n\n### 2.1 Document Head\n\nThe document head includes:\n\n| Element | Description |\n|---|---|\n| Character encoding | UTF-8 |\n| Viewport | Width = device width, initial scale 1 |\n| Meta description | Page-specific, yielded from child templates |\n| Title | Page-specific title appended with the app name |\n| Stylesheets | Compiled CSS via Vite |\n| Scripts | Compiled JS via Vite |\n| Livewire styles | Injected by Livewire |\n| Language attribute | Set on the HTML element from application locale |\n| Scroll behavior | Smooth scrolling enabled globally |\n\n### 2.2 Skip Link\n\nA visually hidden \"Skip to main content\" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.\n\n**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.\n\n### 2.3 Announcement Bar\n\nOptional, controlled by theme settings. Renders a full-width bar above the header.\n\n| Attribute | Description |\n|---|---|\n| Position | Full-width, above the header |\n| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |\n| Text | Centered, small size, configurable content |\n| Link | Optional, underlined, opens in same tab |\n| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |\n| Height | Auto height with vertical padding |\n\n### 2.4 Header\n\nThe header serves as the primary navigation banner.\n\n**Desktop Layout (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |\n| Main Navigation | Center | Horizontal nav with links sourced from the \"main-menu\" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |\n| Search Icon | Right group | Button that opens the search modal |\n| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |\n| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |\n\n**Mobile Layout (below large breakpoint):**\n\n```\n+--------------------------------------------------------------+\n| [Hamburger]           [Logo]                            [Cart]|\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Hamburger menu | Left | Toggle button for mobile navigation drawer |\n| Logo | Center | Same as desktop but horizontally centered |\n| Cart icon | Right | Same as desktop |\n\n**Sticky Header (optional):**\n\nWhen enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.\n\n**Mobile Navigation Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Animation | Slides in from the left side |\n| Height | Full viewport height |\n| Backdrop | Semi-transparent dark overlay behind the drawer |\n| Close button | X icon at the top-right of the drawer |\n| Navigation items | Vertical stack with comfortable tap targets |\n| Submenus | Collapsible accordion pattern with chevron icons |\n| Account link | Positioned at the bottom of the navigation list |\n| Focus behavior | Focus is trapped within the drawer while open |\n| ARIA | Labeled as \"Mobile navigation\" |\n\n### 2.5 Main Content Area\n\nThe main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.\n\n### 2.6 Footer\n\nThe footer is a content-info landmark.\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n|                                                               |\n| [Social Icons]                                                |\n| [Copyright] [Payment Icons]                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n| Store info column | Store name, address (if configured), contact email |\n| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n| Background | Light gray (dark: very dark gray), top border separator |\n\n### 2.7 Cart Drawer (Livewire)\n\nThe cart drawer is always present in the layout as a global Livewire component. It listens for the \"cart-updated\" browser event to refresh and open. See Section 6 for full specification.\n\n### 2.8 Dark Mode\n\nDark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:\n\n| Mode | Implementation |\n|---|---|\n| System preference (default) | Follows OS-level dark mode preference automatically |\n| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |\n| Forced light/dark | Applied at server render time on the HTML element |\n\n**Color Token Patterns:**\n\n| Token | Light Mode | Dark Mode |\n|---|---|---|\n| Page background | White | Near-black |\n| Card/section background | Light gray | Very dark gray |\n| Heading text | Near-black | White |\n| Body text | Medium gray | Light gray |\n| Borders | Light gray | Medium-dark gray |\n| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |\n\n---\n\n## 3. Home Page (GET /)\n\nThe home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:\n\n1. Hero Banner\n2. Featured Collections\n3. Featured Products\n4. Newsletter Signup\n5. Rich Text\n\n### 3.1 Hero Banner Section\n\n**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.\n\n**Desktop (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n|                                                               |\n|  [Background Image / Gradient]                                |\n|                                                               |\n|       [Heading - very large, bold]                            |\n|       [Subheading - medium, regular weight]                   |\n|       [CTA Button - primary, large]                           |\n|                                                               |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |\n| Content | Centered vertically and horizontally, white text |\n| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |\n| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |\n| CTA | Styled as primary button, large. Links to a configured collection or product |\n| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |\n| Padding | Generous vertical and horizontal padding |\n\n**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.\n\n**Accessibility:**\n\n- If background is an image, it is decorative (the content provides meaning)\n- CTA button has descriptive text (e.g., \"Shop the summer collection\")\n\n### 3.2 Featured Collections Section\n\n**Layout:** Section with heading and grid of collection cards.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, large, bold, centered, with generous bottom margin |\n| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |\n| Count | 2 to 4 collections, configurable in theme settings |\n\n**Collection Card:**\n\n```\n+---------------------------+\n|  [Collection Image]       |\n|  3:4 aspect ratio         |\n|  cover-fit, rounded       |\n|                           |\n|  [Title overlay at bottom]|\n|  [Shop now ->]            |\n+---------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |\n| Title | White text overlaid at bottom of image, semi-bold, medium-large size |\n| \"Shop now\" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |\n| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |\n\n**Accessibility:**\n\n- Each card link has the collection title as accessible name\n- Images have alt text matching the collection title\n\n### 3.3 Featured Products Section\n\n**Livewire Component:** FeaturedProducts\n\nThis section uses Livewire for lazy loading to improve initial page load.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, same styling as featured collections heading |\n| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |\n| Products | 4 to 8, configurable. Sourced from a configured collection or \"best sellers\" logic |\n| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |\n\nEach product uses the product-card component (see Section 16).\n\n### 3.4 Newsletter Signup Section\n\n**Livewire Component:** NewsletterSignup\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n|  [Heading: \"Stay in the loop\"]                                |\n|  [Subtext: \"Subscribe for exclusive offers and updates.\"]     |\n|  [Email input] [Subscribe button]                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Light gray (dark mode: darker gray), generous vertical padding |\n| Content | Centered, constrained to a comfortable width |\n| Heading | H2, medium-large, bold |\n| Subtext | Muted color, small top margin |\n| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |\n| Input | Email type, placeholder \"Enter your email\", required |\n| Button | Primary style, \"Subscribe\" text |\n| Success state | Replaces form with \"Thanks for subscribing!\" message |\n| Error state | Inline error below input in red, small text |\n| Loading state | Button text changes to \"Subscribing...\" during submission |\n\n**Accessibility:**\n\n- Label associated with email input (visually hidden if using placeholder)\n- Success/error messages in a polite live region for screen readers\n\n### 3.5 Rich Text Section\n\nSimple section rendering customizable content from theme settings.\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained to comfortable reading width, centered, vertical padding |\n| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |\n| Dark mode | Inverted prose styling |\n\n---\n\n## 4. Collection Page (GET /collections/{handle})\n\n**Livewire Component:** CollectionPage\n\n### 4.1 Collection Header\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > Collections > {Collection Title} |\n| Title | H1, large, bold |\n| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |\n| Spacing | Generous vertical padding |\n\n### 4.2 Toolbar\n\nHorizontal bar between the header and the product grid.\n\n**Desktop:**\n\n```\n+--------------------------------------------------------------+\n| [Filter toggle button]          [X products] [Sort dropdown]  |\n+--------------------------------------------------------------+\n```\n\n**Mobile:**\n\n```\n+--------------------------------------------------------------+\n| [Filter button]                           [Sort dropdown]     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Product count | Small muted text, e.g., \"24 products\" |\n| Sort dropdown | Select element with live Livewire binding |\n\n**Sort Options:**\n\n| Option | Description |\n|---|---|\n| Featured | Default sort order |\n| Price: Low to High | Ascending by price |\n| Price: High to Low | Descending by price |\n| Newest | Descending by creation date |\n| Best Selling | Descending by sales count |\n\n### 4.3 Filter Sidebar\n\n**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.\n**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by \"Filter\" button with filter icon.\n\n**Filter Groups:**\n\nEach filter group is a collapsible section with a heading and toggle chevron.\n\n| Filter Group | Type | Binding | Description |\n|---|---|---|---|\n| Availability | Single checkbox | Live | \"In stock\" toggle |\n| Price Range | Two number inputs | Live with 500ms debounce | \"Min\" and \"Max\" inputs with currency symbol prefix, displayed side by side |\n| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |\n| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |\n\n**Clear All:**\n\n- Link at the top of the filter sidebar: \"Clear all filters\"\n- Only visible when at least one filter is active\n- Small, blue, interactive text\n\n**Active Filter Pills:**\n\n- Displayed above the product grid (below toolbar) when filters are active\n- Each pill: rounded tag with filter value text and a remove (X) button\n- \"Clear all\" link at the end of the pills row\n\n**Mobile Filter Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Position | Full-height overlay from left side |\n| Header | \"Filters\" title + close button (X) |\n| Content | Same filter groups as desktop, stacked vertically |\n| Footer | Sticky \"Apply\" button at bottom that closes the drawer |\n| Clear | \"Clear all\" link in header area |\n| Focus | Trapped within drawer while open |\n\n### 4.4 Product Grid\n\n| Attribute | Description |\n|---|---|\n| Layout | Fills remaining width next to the sidebar |\n| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |\n| Loading state | Subtle opacity reduction on the grid while results update |\n\n### 4.5 Product Card (Component)\n\nThe product card is a reusable component used on collection pages, search results, and featured products sections.\n\n```\n+---------------------------+\n|  [Product Image]          |\n|  1:1 aspect ratio         |\n|  cover-fit, rounded       |\n|  (hover: show 2nd image)  |\n|                           |\n|  [Sale badge]  [Sold out] |\n+---------------------------+\n|  [Product Title]          |\n|  [Price]  [Compare price] |\n|  [Quick Add / Options]    |\n+---------------------------+\n```\n\n**Image Behavior:**\n\n| Attribute | Description |\n|---|---|\n| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |\n| Primary image | Cover-fit, fills the container |\n| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |\n| Fallback | If no image, show a centered shopping bag placeholder icon |\n| Loading | Lazy-loaded |\n\n**Badges:**\n\n| Badge | Condition | Position |\n|---|---|---|\n| \"Sale\" | compare_at_amount > price_amount | Top-left of image, stacked vertically |\n| \"Sold out\" | All variants out of stock with deny policy | Top-left of image, stacked vertically |\n\n**Text Content:**\n\n| Element | Description |\n|---|---|\n| Title | H3 (or configurable heading level), small semi-bold text. Truncated to 2 lines |\n| Price | Current price in small semi-bold text. Compare-at price shown with strikethrough if applicable |\n| Quick Add | Single variant: \"Add to cart\" button. Multiple variants: \"Choose options\" link text. Hidden by default on desktop (shown on hover), always visible on mobile |\n\n**Accessibility:**\n\n- Card link wraps the entire card; title serves as the accessible name\n- Badges have screen reader labels (e.g., \"On sale\")\n- Price changes announced via parent live region when filtering changes results\n\n### 4.6 Pagination\n\n| Attribute | Description |\n|---|---|\n| Style | Numbered pagination with previous/next arrow buttons |\n| Current page | Highlighted with primary color background and white text |\n| Other pages | Muted text, hover background |\n| Disabled | Reduced opacity and not-allowed cursor on previous (at first page) or next (at last page) |\n| Truncation | Ellipsis for large page counts |\n| Mobile | Simplified: previous/next only with current page indicator |\n\n### 4.7 Empty State\n\nWhen no products match the current filters:\n\n```\n+--------------------------------------------------------------+\n|  [Search icon - large, muted]                                 |\n|  \"No products found\"                                          |\n|  \"Try adjusting your filters or browse our full collection.\"  |\n|  [Clear filters button]                                       |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Icon | Large, muted search icon |\n| Heading | Medium, semi-bold |\n| Subtext | Muted, descriptive guidance |\n| Button | Secondary style, \"Clear filters\" |\n| Position | Centered in the grid area with generous vertical padding |\n\n---\n\n## 5. Product Page (GET /products/{handle})\n\n**Livewire Component:** ProductPage\n\n### 5.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column, 50/50 split. Left column (images) is sticky while the right column (product info) scrolls.\n\n**Mobile:** Single column, stacked. Images first, then product info.\n\n```\nDesktop:\n+-------------------------------+-------------------------------+\n|  [Image Gallery]              |  [Product Info]               |\n|  (sticky while scrolling)     |  (scrollable)                 |\n+-------------------------------+-------------------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Image Gallery - horizontal scroll]                          |\n+--------------------------------------------------------------+\n|  [Product Info - full width]                                  |\n+--------------------------------------------------------------+\n```\n\nContainer constrained to standard max-width with horizontal padding and vertical spacing.\n\n### 5.2 Image Gallery (Left Column)\n\n**Desktop:**\n\n```\n+-------------------------------+\n|                               |\n|  [Main Image - large]         |\n|  1:1 aspect ratio, rounded    |\n|                               |\n+-------------------------------+\n| [Thumb] [Thumb] [Thumb] ...  |\n+-------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Main image | Square aspect ratio, large rounded corners, cover-fit. Gray placeholder background |\n| Zoom on hover | Optional (theme setting): image scales to 150% and follows cursor position |\n| Thumbnail strip | Horizontal row with overflow scroll, small gap between thumbnails |\n| Each thumbnail | 64x64px, rounded, cover-fit, pointer cursor. Active thumbnail has a colored border |\n| Variant images | When a variant is selected that has specific images, those images are shown |\n\n**Mobile:**\n\n| Attribute | Description |\n|---|---|\n| Gallery | Horizontal scrollable row with snap-scroll behavior |\n| Each image | Full-width, square aspect ratio, rounded, cover-fit |\n| Scroll indicators | Row of dots below the gallery. Active dot is dark (light in dark mode), inactive dots are gray |\n\n**Accessibility:**\n\n| Requirement | Description |\n|---|---|\n| Alt text | Main image has descriptive alt text from product media data |\n| Thumbnails | Rendered as buttons with labels \"View image X of Y\" and current state indicator |\n| Region | Gallery wrapped in a labeled region (\"Product images\") |\n\n### 5.3 Product Info (Right Column)\n\n#### Breadcrumbs\n\nHome > {Primary Collection} > {Product Title}. Small, muted text with separator characters.\n\n#### Product Title\n\nH1, large, bold.\n\n#### Price Display\n\n| Element | Description |\n|---|---|\n| Current price | Large, bold |\n| Compare-at price | Medium, muted, strikethrough (shown when set and higher than current price) |\n| Sale badge | Inline badge next to the price |\n| Currency | Formatted based on the store's default currency |\n| Live updates | Price container is a live region, so price changes when variant changes are announced to screen readers |\n\n#### Variant Selector\n\nFor each product option (e.g., Size, Color), render a selector group.\n\n**Radio button style (for options with 6 or fewer values):**\n\n```\nSize\n[S] [M] [L] [XL] [XXL]\n```\n\n| State | Visual Description |\n|---|---|\n| Available | Bordered pill shape, pointer cursor, border darkens on hover |\n| Selected | Primary color border with subtle background tint and focus ring |\n| Unavailable | Muted border, muted text, strikethrough, reduced opacity, not-allowed cursor |\n\n**Select dropdown style:** Used when the option has more than 6 values.\n\n**Color swatches (when option name is \"Color\"):**\n\n| Attribute | Description |\n|---|---|\n| Shape | Circular, 32x32px |\n| Color | Determined by value name mapped to a color lookup or inline style |\n| Selected state | Ring with offset around the swatch |\n| Tooltip | Shows color name on hover |\n\n**Livewire behavior:**\n\n- Selected options are a key-value map (e.g., Size = M, Color = Red)\n- Selected variant is computed by matching all selected options to the variant option values\n- On change: updates price, stock messaging, available images, and add-to-cart availability\n\n#### Stock Messaging\n\nDisplayed below the variant selector.\n\n| Condition | Message | Visual Style |\n|---|---|---|\n| In stock, quantity > 10 | \"In stock\" | Green text with check icon |\n| In stock, quantity <= 10 | \"Only X left in stock\" | Amber text with warning icon |\n| Out of stock, deny policy | \"Out of stock\" | Red text with X icon |\n| Out of stock, continue policy | \"Available on backorder\" | Blue text with info icon |\n\nSmall text, icon + text inline. Wrapped in a live region for screen reader announcements on variant change.\n\n#### Quantity Selector\n\nUses the quantity-selector component.\n\n```\n[-] [3] [+]\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Inline flex, bordered, rounded |\n| Decrease button | \"-\" icon, 40x40px. Disabled at minimum (1) |\n| Input | Centered number input, 56px wide, 40px tall. Min = 1, Max = available stock (deny policy) or unlimited (continue policy) |\n| Increase button | \"+\" icon, same size. Disabled at maximum |\n| Disabled state | Reduced opacity, not-allowed cursor |\n| Labels | Input labeled \"Quantity\", buttons labeled \"Decrease quantity\" / \"Increase quantity\" |\n\n#### Add to Cart Button\n\n| Attribute | Description |\n|---|---|\n| Width | Full width |\n| Style | Primary button: blue background, white text, semi-bold, generous padding, rounded |\n| Text | \"Add to cart\" |\n| Loading state | Text changes to \"Adding...\" with spinner icon. Button disabled during loading |\n| Sold out state | Gray background, muted text, not-allowed cursor. Text: \"Sold out\" |\n| Spacing | Generous top margin |\n\n**Livewire action (addToCart):**\n\n1. Validates variant is selected and in stock\n2. Adds to cart (creates cart if none exists in session)\n3. Dispatches \"cart-updated\" browser event\n4. Cart drawer opens automatically\n\n#### Product Description\n\n- Separated from above content by a horizontal rule with generous vertical spacing\n- Rendered HTML styled with prose/typography styling\n\n#### Tags\n\n- Displayed below the description (if tags exist)\n- Horizontal wrapping row with gap\n- Each tag: rounded pill, gray background, small text\n\n### 5.4 Accessibility Summary\n\n| Requirement | Implementation |\n|---|---|\n| Variant radio buttons | Properly labeled with labels and grouped with fieldset/legend |\n| Live regions | Price display, stock messaging, add-to-cart feedback |\n| Image alt text | All images have meaningful alt text |\n| Keyboard | Tab through thumbnails, variant options, quantity controls, and add-to-cart |\n| Focus indicators | Visible ring styles on all interactive elements |\n\n---\n\n## 6. Cart Drawer\n\n**Livewire Component:** CartDrawer\n\n### 6.1 Trigger and Animation\n\n| Attribute | Description |\n|---|---|\n| Trigger | \"cart-updated\" browser event (from add-to-cart actions), or clicking the cart icon in the header |\n| Animation | Slides in from the right side of the viewport. Transforms from fully off-screen to visible |\n| Backdrop | Fixed full-screen semi-transparent dark overlay. Click to close |\n| Panel | Fixed, right-aligned, full height, full width on small screens or 384px on larger screens. White background (dark mode: near-black), shadow |\n| Body | Scroll locked when drawer is open |\n\n### 6.2 Drawer Header\n\n```\n+--------------------------------------+\n| Your Cart (3)                    [X] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Heading | H2, semi-bold, medium-large text |\n| Item count | In parentheses after heading |\n| Close button | X icon, labeled \"Close cart\" |\n| Layout | Flex row, space-between, with horizontal padding and bottom border |\n\n### 6.3 Cart Line Items\n\nScrollable list area occupying the space between header and totals.\n\nEach line item:\n\n```\n+--------------------------------------+\n| [Thumb]  Title                       |\n|          Variant: Size M / Blue      |\n|          [-] [2] [+]       $49.98    |\n|                             [Remove] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Thumbnail | 64x64px, rounded, cover-fit, non-shrinking |\n| Title | Small semi-bold text, truncated to 1 line |\n| Variant info | Extra-small muted text showing selected options |\n| Quantity stepper | Compact version of quantity-selector (smaller buttons, 32x32px) |\n| Line total | Small semi-bold text |\n| Remove button | Trash icon, muted color, turns red on hover. Labeled \"Remove {item title} from cart\" |\n| Separation | Bottom border between items, vertical padding per item |\n| Loading | Opacity reduction on the line item during quantity update |\n\n### 6.4 Discount Code Section\n\nBelow the line items list, above totals.\n\n**No discount applied:**\n\n```\n+--------------------------------------+\n| [Discount code input] [Apply]        |\n+--------------------------------------+\n```\n\n**Discount applied:**\n\n```\n+--------------------------------------+\n| SUMMER20 (-15%)              [Remove]|\n+--------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Input | Small text, placeholder \"Discount code\" |\n| Apply button | Secondary style, small |\n| Success state | Green text showing the code and discount type, with a remove button |\n| Error state | Small red text below, e.g., \"Invalid discount code\" or \"This code has expired\" |\n| Loading state | Button text changes to \"Applying...\" |\n\n### 6.5 Totals Section\n\n```\n+--------------------------------------+\n| Subtotal                     $149.97 |\n| Discount (SUMMER20)          -$22.50 |\n| ------------------------------------ |\n| Estimated total              $127.47 |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Position | Sticky at the bottom of the drawer, with top border |\n| Each line | Flex row, justified space-between, small text |\n| Discount line | Only shown when discount applied, green text |\n| Total | Slightly larger text, semi-bold |\n| Note | \"Shipping and taxes calculated at checkout\" in extra-small muted text below totals |\n\n### 6.6 Action Buttons\n\n| Button | Description |\n|---|---|\n| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |\n| Continue shopping | Centered text link below checkout button. Small muted text, hover to darker. Closes the drawer |\n\n### 6.7 Empty Cart State\n\n```\n+--------------------------------------+\n|                                      |\n|  [Shopping bag icon - large]         |\n|  \"Your cart is empty\"                |\n|  [Continue shopping button]          |\n|                                      |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Centered vertically and horizontally in the drawer content area |\n| Icon | Large, very muted shopping bag icon |\n| Text | Muted, with top margin |\n| Button | Secondary style, closes the drawer |\n\n### 6.8 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Role | Dialog with modal behavior, labeled \"Shopping cart\" |\n| Focus trap | Tab/Shift+Tab cycles through drawer elements only |\n| On open | Focus moves to the close button or first interactive element |\n| On close | Focus returns to the element that triggered the drawer (cart icon) |\n| Escape key | Closes the drawer |\n| Feedback | All quantity changes and removals announced via polite live region |\n\n---\n\n## 7. Full Cart Page (GET /cart)\n\n**Livewire Component:** CartPage\n\nSame functionality as the cart drawer but in a full-page layout.\n\n### 7.1 Desktop Layout\n\n```\n+--------------------------------------------------------------+\n|  Your Cart                                                    |\n+--------------------------------------------------------------+\n|  Image | Product      | Price   | Quantity | Total   |       |\n|  ------|------------- |---------|----------|---------|-------|\n|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n|        | Size: M      |         |          |         |       |\n|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n|        | Color: Blue  |         |          |         |       |\n+--------------------------------------------------------------+\n|                                            Subtotal: $84.97   |\n|                              [Discount code] [Apply]          |\n|                                            Discount: -$12.75  |\n|                                            Total:    $72.22   |\n|                                                               |\n|                                            [Checkout button]  |\n|                                            [Continue shopping]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n| Title | H1, large bold, generous bottom margin |\n| Desktop layout | Proper table with header row and body |\n| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n\n### 7.2 Totals and Actions\n\n- Right-aligned on desktop, full-width on mobile\n- Same discount code input/apply functionality as drawer\n- Same totals breakdown\n- \"Checkout\" button: primary, aligned right\n- \"Continue shopping\" link: below checkout button\n\n### 7.3 Empty State\n\nSame as drawer empty state but centered in the page content area with more vertical padding.\n\n---\n\n## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment\n\n| Element | Description |\n|---|---|\n| Order review | Brief summary of items, address, and shipping method |\n| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |\n| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |\n| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.\n\n```\n+--------------------------------------------------------------+\n|  [Checkmark icon - green circle]                              |\n|  \"Thank you for your order!\"                                  |\n|  Order #1042                                                  |\n|  \"We've sent a confirmation to you@email.com\"                 |\n+--------------------------------------------------------------+\n|  Order Summary                                                |\n|  [Items list with thumbnails]                                 |\n+--------------------------------------------------------------+\n|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Visa ending in 4242             |\n|  123 Main St               |                                  |\n|  New York, NY 10001        |                                  |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  [Continue shopping button]  [View order - if logged in]      |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained to a comfortable width, centered, generous vertical padding |\n| Success icon | Large green checkmark in a circle, centered |\n| Heading | H1, large bold, centered, generous top margin |\n| Order number | Medium muted text, centered |\n| Email notice | Small muted text, centered |\n\n### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Totals | Right-aligned summary |\n\n### 9.3 Actions\n\n| Button | Description |\n|---|---|\n| Continue shopping | Primary, links to home page |\n| View order | Link to account order detail page (logged-in customers only) |\n| Layout | Centered row with gap |\n\n---\n\n## 10. Customer Account Pages\n\nAll account pages require authentication. Unauthenticated access redirects to the login page.\n\n### 10.1 Login (GET /account/login)\n\n**Livewire Component:** Login\n\n```\n+--------------------------------------------------------------+\n|  Log in to your account                                       |\n|                                                               |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Forgot password? link]                                      |\n|  [Log in button]                                              |\n|                                                               |\n|  Don't have an account? [Create one]                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, autocomplete for current password |\n| Forgot password | Small blue link, right-aligned |\n| Submit button | Primary, full-width, \"Log in\" |\n| Error messages | Inline below fields in small red text |\n| General error | Invalid credentials shown in a callout/alert above the form |\n| Register link | \"Create one\" link to registration page |\n\n### 10.2 Register (GET /account/register)\n\n**Livewire Component:** Register\n\n```\n+--------------------------------------------------------------+\n|  Create an account                                            |\n|                                                               |\n|  [Name input]                                                 |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Confirm password input]                                     |\n|  [ ] Subscribe to marketing emails                            |\n|  [Create account button]                                      |\n|                                                               |\n|  Already have an account? [Log in]                            |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Name | Text type, required, autocomplete for name |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, minimum 8 characters, autocomplete for new password |\n| Confirm password | Password type, required, autocomplete for new password |\n| Marketing checkbox | Checkbox with label \"Subscribe to marketing emails\" |\n| Submit button | Primary, full-width, \"Create account\" |\n| On success | Redirect to account dashboard |\n\n### 10.3 Account Dashboard (GET /account)\n\n```\n+--------------------------------------------------------------+\n|  Welcome back, Jane!                                          |\n+--------------------------------------------------------------+\n|  [Orders card]     [Addresses card]     [Log out card]        |\n+--------------------------------------------------------------+\n|  Recent Orders                                                |\n|  # | Date       | Status  | Total   | Action                 |\n|  --|------------|---------|---------|--------                 |\n|  1042 | Jan 15  | Paid    | $98.70  | View                   |\n|  1041 | Jan 10  | Shipped | $45.00  | View                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained max-width, centered, with padding |\n| Welcome heading | H1, medium-large bold |\n| Quick links | Responsive grid: 1 column on mobile, 3 on tablet+. Each card has border, rounded corners, padding, hover shadow |\n| Orders card | Order icon, \"Order history\", \"View all your orders\" |\n| Addresses card | Location icon, \"Addresses\", \"Manage your addresses\" |\n| Log out card | Logout icon, \"Log out\", submits POST logout form |\n| Recent orders | Table showing last 5 orders. Status shown as badge component |\n\n### 10.4 Order History (GET /account/orders)\n\n| Attribute | Description |\n|---|---|\n| Heading | H1, \"Order History\" |\n| Table columns | Order (link), Date, Status (badge), Total, Action (\"View\" link) |\n| Status badge colors | Pending: yellow. Paid: green. Fulfilled: blue. Cancelled: gray. Refunded: red |\n| Mobile | Card-based layout instead of table. Each card shows order number, date, status, and total |\n| Pagination | Standard pagination component |\n\n### 10.5 Order Detail (GET /account/orders/{orderNumber})\n\n```\n+--------------------------------------------------------------+\n|  Order #1042                      Status: [Paid] [Fulfilled] |\n|  Placed on January 15, 2026                                  |\n+--------------------------------------------------------------+\n|  Items                                                        |\n|  [Thumb] Product Title x2              $49.98                 |\n|          Size: M                                              |\n+--------------------------------------------------------------+\n|  Shipping Address    |  Billing Address   | Payment           |\n|  Jane Doe            |  Same as shipping  | Visa ****4242     |\n|  123 Main St         |                    |                   |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Discount        -$0.00                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  Fulfillment                                                  |\n|  Shipped via UPS - 1Z999AA10123456784                         |\n|  [Track shipment ->]                                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Account > Orders > #1042 |\n| Header | Order number and status badges |\n| Date | Small muted text |\n| Items | Table with thumbnail, title + variant info, quantity, line total |\n| Info grid | Three columns on desktop (shipping, billing, payment), stacked on mobile |\n| Totals | Summary breakdown |\n| Fulfillment | Only shown if fulfillments exist. Shows tracking company, number, and link to tracking URL (opens in new tab safely) |\n\n### 10.6 Address Book (GET /account/addresses)\n\n**Livewire Component:** AddressBook\n\n```\n+--------------------------------------------------------------+\n|  Your Addresses                          [+ Add new address]  |\n+--------------------------------------------------------------+\n|  [Default badge]              |                               |\n|  Jane Doe                     |  Jane Doe                     |\n|  123 Main St                  |  456 Oak Ave                  |\n|  New York, NY 10001           |  Chicago, IL 60601            |\n|  United States                |  United States                |\n|  [Edit] [Delete]              |  [Edit] [Delete] [Set default]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Grid | 1 column on mobile, 2 on tablet, 3 on desktop |\n| Each card | Bordered, rounded, with padding. Default address has a primary-colored border and \"Default\" badge |\n| Content | Formatted address lines |\n| Actions | Edit and Delete text buttons, with \"Set as default\" on non-default addresses |\n| Add new | Opens modal with address-form component |\n| Edit | Opens same modal pre-filled |\n| Delete | Confirmation dialog before deleting |\n\n---\n\n## 11. Search\n\n### 11.1 Search Modal\n\n**Livewire Component:** SearchModal\n\nTriggered by clicking the search icon in the header.\n\n```\n+--------------------------------------------------------------+\n|  [Search input with icon]                           [X close] |\n+--------------------------------------------------------------+\n|  Products                                                     |\n|  [Thumb] Product Title            $24.99                      |\n|  [Thumb] Product Title            $34.99                      |\n|                                                               |\n|  Collections                                                  |\n|  Summer Collection                                            |\n|  Winter Collection                                            |\n|                                                               |\n|  [View all X results ->]                                      |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Overlay | Full-screen semi-transparent dark backdrop |\n| Modal | Centered, moderate max-width, rounded corners, shadow |\n| Search input | Large text, search icon prefix, autofocus on open. Live binding with 300ms debounce |\n| Results | Appear below input as user types. Grouped by type: \"Products\" and \"Collections\" headings |\n| Product results | Thumbnail, title, price. Clickable to product page |\n| Collection results | Title only. Clickable to collection page |\n| Max results | 5 per category in the modal |\n| View all | Link at the bottom navigating to the full search results page |\n| Loading | Skeleton lines while fetching |\n| No results | \"No results for '{query}'\" message |\n| Keyboard | Escape closes, arrow keys navigate results, Enter selects highlighted result |\n| Focus | Trapped within the modal |\n| ARIA | Dialog role, modal, labeled \"Search\". Results list as listbox with option items |\n\n### 11.2 Search Results Page (GET /search?q=)\n\n**Livewire Component:** SearchResults\n\nSame layout as the collection page (Section 4) with the following differences:\n\n| Difference | Description |\n|---|---|\n| Heading | H1, \"X results for '{query}'\" or \"Search results for '{query}'\" |\n| Breadcrumbs | Home > Search results |\n| Filters | Same filter sidebar (availability, price, type, vendor) |\n| Sort | Same sort dropdown |\n| Product grid | Same product card component |\n| Pagination | Same pagination |\n| Empty state | \"No results found for '{query}'. Try a different search term.\" with a search input to try again |\n\n---\n\n## 12. Content Pages (GET /pages/{handle})\n\n### 12.1 Layout\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > {Page Title} |\n| Title | H1, large bold |\n| Body | Rendered HTML from body_html, styled with prose/typography styling, constrained to comfortable reading width (configurable to full width via theme settings) |\n| Container | Standard max-width, centered, with padding |\n\n---\n\n## 13. Error Pages\n\n### 13.1 404 Page\n\n```\n+--------------------------------------------------------------+\n|  [404 - large, bold, very muted]                              |\n|  \"Page not found\"                                             |\n|  \"The page you're looking for doesn't exist or has been       |\n|   moved.\"                                                     |\n|                                                               |\n|  [Search input]                                               |\n|  [Go to home page button]                                     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered vertically and horizontally |\n| 404 text | Very large, bold, extremely muted (barely visible as a background element) |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Search | Simple input + button, submits to search page |\n| Home link | Secondary button |\n\n### 13.2 503 / Maintenance Page\n\n```\n+--------------------------------------------------------------+\n|  [Store logo]                                                 |\n|  \"We'll be back soon\"                                         |\n|  \"We're currently performing maintenance. Please check back   |\n|   shortly.\"                                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered column |\n| Logo | Standard height, centered |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Style | Consistent with the store theme (colors, fonts) |\n\n---\n\n## 14. Responsive Breakpoints\n\nDefault breakpoints used across the storefront:\n\n| Breakpoint | Min-width | Typical Devices |\n|---|---|---|\n| Base (none) | 0px | Mobile phones (portrait) |\n| Small | 640px | Mobile phones (landscape), small tablets |\n| Medium | 768px | Tablets (portrait) |\n| Large | 1024px | Tablets (landscape), small desktops |\n| Extra-large | 1280px | Desktops |\n\n### Key Responsive Behaviors\n\n| Component | Mobile (base) | Tablet (medium) | Desktop (large+) |\n|---|---|---|---|\n| Navigation | Hamburger + drawer | Hamburger + drawer | Horizontal nav bar |\n| Product grid | 2 columns | 3 columns | 3-4 columns |\n| Product page | Stacked (images, then info) | Stacked | Side-by-side 50/50 |\n| Cart drawer | Full-screen overlay | 384px sidebar | 384px sidebar |\n| Checkout | Single column, summary collapsed | Single column | Two columns (form + summary) |\n| Footer | Stacked columns | 2-column grid | 3-4 column grid |\n| Collection filters | Drawer overlay | Drawer overlay | Persistent sidebar |\n| Account tables | Card-based layout | Card-based layout | Table layout |\n\n### Touch Considerations\n\n| Consideration | Requirement |\n|---|---|\n| Tap targets | Minimum 44x44px for all interactive elements on mobile |\n| Hover effects | Only applied on devices with hover capability |\n| Swipe gestures | Horizontal swipe on product images (mobile gallery), cart drawer close |\n\n---\n\n## 15. Accessibility Checklist\n\n### Global (All Pages)\n\n| Requirement | Description |\n|---|---|\n| Skip link | \"Skip to main content\" link, first element in body |\n| Landmark roles | Banner for header, main for content, contentinfo for footer, labeled nav elements |\n| Heading hierarchy | Single H1 per page, logical H2-H6 nesting |\n| Focus indicators | Visible ring on all interactive elements with offset |\n| Color contrast | Minimum 4.5:1 for normal text, 3:1 for large text (WCAG AA) |\n| Keyboard navigation | All interactive elements reachable via Tab, operable via Enter/Space |\n| Screen reader text | Visually hidden text for icon-only buttons (close, cart, search) |\n| Language | Lang attribute on HTML element |\n| Images | All images have alt attributes; decorative images marked appropriately |\n\n### Page-Specific Accessibility\n\n| Page | Requirements |\n|---|---|\n| Collection Page | Live region for product count/grid on filter change. Filter sidebar labeled as navigation. Sort dropdown has associated label |\n| Product Page | Variant selector uses fieldset/legend grouping. Price and stock in live regions. Image gallery thumbnails as buttons with current state |\n| Cart Drawer | Dialog role with modal behavior, labeled \"Shopping cart\". Focus trap. Quantity changes in live region |\n| Checkout | Form validation with describedby linking to errors, invalid state on fields. Focus management on step transitions. Shipping radios in fieldset/legend. Totals in live region |\n| Search Modal | Dialog role with modal behavior. Results as listbox with option items. Active descendant tracking for keyboard navigation |\n| Account Pages | All form inputs labeled. Tables use proper header markup with column scope. Status badges readable by text (not color-only) |\n\n### Keyboard Interactions\n\n| Component | Key | Action |\n|---|---|---|\n| Hamburger menu | Enter/Space | Toggle open/close |\n| Mobile nav drawer | Escape | Close |\n| Dropdown menus | Arrow Up/Down | Navigate items |\n| Dropdown menus | Escape | Close |\n| Cart drawer | Escape | Close |\n| Search modal | Escape | Close |\n| Search results | Arrow Up/Down | Navigate results |\n| Search results | Enter | Select highlighted result |\n| Variant radio buttons | Arrow Left/Right | Navigate options |\n| Quantity input | Arrow Up/Down | Increment/decrement |\n| Modals/dialogs | Tab/Shift+Tab | Cycle focus within (trap) |\n\n---\n\n## 16. Component Library\n\n### storefront-layout\n\nMaster layout wrapper.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| title | string | App name from config | Page title |\n| metaDescription | string | Empty | Meta description tag |\n\n### storefront-product-card\n\nRenders a single product card in grid contexts.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| product | Product model | Required | Product with variants, media, and inventory eager-loaded |\n| headingLevel | string | h3 | Heading tag for the product title |\n| showQuickAdd | boolean | true | Whether to show the quick add / choose options action |\n\n**Eager-load requirements:** product.variants.inventoryItem, product.media (sorted by position).\n\n### storefront-price\n\nDisplays a formatted price with optional compare-at price.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| amount | integer | Required | Price in minor units (cents) |\n| currency | string | USD | ISO 4217 currency code |\n| compareAtAmount | integer or null | null | Original price in minor units for sale display |\n\n**Rendering:** Formats amount using Laravel's Number currency helper. If compareAtAmount is set and greater than amount, shows compare-at price with strikethrough styling and a \"Sale\" badge.\n\n### storefront-badge\n\nRenders a styled badge/tag.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| text | string | Required | Badge text |\n| variant | string | default | Visual variant: sale, sold-out, new, default |\n\n**Variant Styles:**\n\n| Variant | Background | Text Color |\n|---|---|---|\n| sale | Light red (dark: dark red tint) | Red |\n| sold-out | Light gray (dark: dark gray) | Medium gray |\n| new | Light blue (dark: dark blue tint) | Blue |\n| default | Light gray (dark: dark gray) | Dark gray |\n\n**Base appearance:** Inline pill shape, rounded-full, extra-small medium-weight text with horizontal padding.\n\n### storefront-quantity-selector\n\nNumber input with increment/decrement buttons.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| value | integer | 1 | Current quantity |\n| min | integer | 1 | Minimum allowed value |\n| max | integer or null | null | Maximum allowed value (null = unlimited) |\n| wireModel | string | Required | Livewire model binding string |\n| compact | boolean | false | Smaller variant for cart drawer |\n\n### storefront-address-form\n\nRenders a full address form with all fields.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| address | array or null | null | Pre-filled address data |\n| prefix | string | Empty | Livewire model prefix (e.g., \"shipping\" for shipping.firstName) |\n\n### storefront-order-summary\n\nRenders the checkout order summary sidebar.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |\n| showDiscountInput | boolean | true | Whether to show the discount code input |\n\n### storefront-breadcrumbs\n\nRenders a breadcrumb navigation trail.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| items | array | Required | Array of objects with label and url. Last item has no URL (current page) |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Breadcrumb\"\n- Ordered list with separator characters between items\n- Current page (last item) has a \"current page\" indicator and distinct styling\n- Includes structured data (BreadcrumbList schema) for SEO\n\n### storefront-pagination\n\nWraps Laravel's pagination with custom styling.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| paginator | LengthAwarePaginator | Required | Laravel paginator instance |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Pagination\"\n- Previous/Next buttons with arrow icons\n- Numbered page buttons with current page highlighted\n- Ellipsis for truncated ranges\n- Mobile: previous/next only with \"Page X of Y\" text\n\n---\n\n## 17. Livewire Component Summary\n\n| Component | Location | Purpose |\n|---|---|---|\n| FeaturedProducts | Home page | Lazy-loaded featured products grid |\n| NewsletterSignup | Home page, footer | Email subscription form |\n| CollectionPage | Collection page | Filtering, sorting, pagination |\n| ProductPage | Product page | Variant selection, add to cart |\n| CartDrawer | Layout (global) | Slide-in cart with line management |\n| CartPage | Cart page | Full-page cart |\n| CheckoutPage | Checkout | Multi-step checkout flow |\n| SearchModal | Layout (global) | Autocomplete search overlay |\n| SearchResults | Search page | Full search results with filters |\n| Login | Login page | Authentication form |\n| Register | Register page | Registration form |\n| AddressBook | Addresses page | CRUD for saved addresses |\n\n---\n\n## 18. Browser Event Communication\n\nLivewire components communicate via dispatched browser events:\n\n| Event Name | Dispatched By | Listened By | Payload |\n|---|---|---|---|\n| cart-updated | ProductPage, CartDrawer, CartPage | CartDrawer (opens + refreshes), Header (badge count) | cartId, itemCount |\n| open-cart-drawer | Header cart icon click | CartDrawer | None |\n| close-cart-drawer | CartDrawer close actions | CartDrawer | None |\n| open-search-modal | Header search icon click | SearchModal | None |\n| close-search-modal | SearchModal close actions | SearchModal | None |\n| variant-changed | ProductPage (variant selector) | ProductPage (internal) | variantId, price, stock |\n\n---\n\n## 19. SEO and Structured Data\n\n### Meta Tags\n\nEach page type sets appropriate meta tags:\n\n| Page | Title Pattern | Description Source |\n|---|---|---|\n| Home | Store name | Theme settings |\n| Collection | \"{Collection title} - {Store name}\" | Collection description (stripped of markup) |\n| Product | \"{Product title} - {Store name}\" | Product description (stripped, truncated to 160 chars) |\n| Content page | \"{Page title} - {Store name}\" | Page body (stripped, truncated) |\n| Search | \"Search results - {Store name}\" | Static |\n\n### Open Graph Tags\n\nProduct pages include Open Graph tags for social sharing:\n\n| Tag | Value |\n|---|---|\n| og:title | Product title |\n| og:description | Meta description |\n| og:image | Primary product image URL |\n| og:type | product |\n| og:url | Current page URL |\n| product:price:amount | Formatted price (dollars) |\n| product:price:currency | Store currency code |\n\n### JSON-LD Structured Data\n\n| Schema Type | Used On |\n|---|---|\n| Product | Product pages |\n| BreadcrumbList | Breadcrumb navigation on all pages |\n| Organization | Footer (store info) |\n\n---\n\n## 20. User Flow Diagrams\n\n### Browse to Purchase Flow\n\n```mermaid\nflowchart TD\n    A[Home Page] --> B{Browse}\n    B --> C[Collection Page]\n    B --> D[Search Modal]\n    D --> E[Search Results Page]\n    C --> F[Product Page]\n    E --> F\n    F --> G{Select Variant}\n    G --> H[Set Quantity]\n    H --> I[Add to Cart]\n    I --> J[Cart Drawer Opens]\n    J --> K{Continue?}\n    K -->|Continue Shopping| B\n    K -->|Apply Discount| L[Enter Discount Code]\n    L --> J\n    K -->|Checkout| M[Checkout Page]\n    M --> N[Step 1: Contact Info]\n    N --> O[Step 2: Shipping Address]\n    O --> P[Step 3: Shipping Method]\n    P --> Q[Step 4: Payment]\n    Q --> R[Stripe/PayPal Redirect]\n    R --> S[Order Confirmation]\n    S --> T{Next?}\n    T -->|Continue Shopping| A\n    T -->|View Order| U[Account Order Detail]\n```\n\n### Account Management Flow\n\n```mermaid\nflowchart TD\n    A[Login Page] --> B{Has Account?}\n    B -->|No| C[Register Page]\n    C --> D[Account Dashboard]\n    B -->|Yes| D\n    D --> E{Action}\n    E --> F[Order History]\n    F --> G[Order Detail]\n    G --> H[Track Shipment]\n    E --> I[Address Book]\n    I --> J{Action}\n    J --> K[Add Address]\n    J --> L[Edit Address]\n    J --> M[Delete Address]\n    J --> N[Set Default Address]\n    E --> O[Log Out]\n```\n\n### Cart Management Flow\n\n```mermaid\nflowchart TD\n    A[Add to Cart] --> B[Cart Drawer Opens]\n    B --> C{Action}\n    C --> D[Update Quantity]\n    D --> B\n    C --> E[Remove Item]\n    E --> F{Cart Empty?}\n    F -->|Yes| G[Empty Cart State]\n    G --> H[Continue Shopping]\n    F -->|No| B\n    C --> I[Apply Discount Code]\n    I --> J{Valid?}\n    J -->|Yes| K[Discount Applied]\n    K --> B\n    J -->|No| L[Error Message]\n    L --> B\n    C --> M[Proceed to Checkout]\n    M --> N[Checkout Page]\n    C --> O[View Full Cart Page]\n    O --> N\n```\n\n### Component State Diagram\n\n```mermaid\nstateDiagram-v2\n    [*] --> Empty: Cart initialized\n\n    state \"Cart Drawer\" as CD {\n        Empty --> HasItems: Add item\n        HasItems --> HasItems: Update quantity\n        HasItems --> HasItems: Apply discount\n        HasItems --> Empty: Remove last item\n        HasItems --> Checkout: Proceed to checkout\n    }\n\n    state \"Product Page\" as PP {\n        NoVariant --> VariantSelected: Select options\n        VariantSelected --> InStock: Stock > 0\n        VariantSelected --> OutOfStock: Stock = 0\n        InStock --> Adding: Click add to cart\n        Adding --> InStock: Complete\n        OutOfStock --> SoldOut: Deny policy\n        OutOfStock --> Backorder: Continue policy\n    }\n\n    state \"Checkout\" as CO {\n        Contact --> ShippingAddress: Complete step 1\n        ShippingAddress --> ShippingMethod: Complete step 2\n        ShippingMethod --> Payment: Complete step 3\n        Payment --> Processing: Submit payment\n        Processing --> Confirmation: Payment success\n        Processing --> Payment: Payment failure\n    }\n```\n\n---\n\n## 21. Flash / Toast Messages\n\n| Action | Message | Type |\n|--------|---------|------|\n| Item added to cart | \"Added to cart\" | success |\n| Item removed from cart | \"Item removed\" | success |\n| Cart is empty | \"Your cart is empty\" | info |\n| Discount applied | \"Discount applied\" | success |\n| Invalid discount code | \"Invalid discount code\" | error |\n| Out of stock | \"This product is currently out of stock\" | warning |\n| Registration success | \"Account created successfully\" | success |\n| Login success | (redirect, no message) | - |\n| Login failed | \"Invalid credentials\" | error |\n| Password reset sent | \"We have emailed your password reset link!\" | success |\n| Order placed | \"Thank you for your order!\" | success |\n","structuredPatch":[{"oldStart":1039,"oldLines":14,"newStart":1039,"newLines":80,"lines":[" "," Continue button: primary."," ","-#### Step 4 - Payment","+#### Step 4 - Payment Method & Pay"," ","+All payment is handled onsite via the Mock PSP. No external redirects, no iframes, no third-party scripts.","+","+**Payment method selection (radio buttons):**","+","+```","++--------------------------------------------------------------+","+| Select a payment method                                       |","+|                                                               |","+| (o) Credit Card                                               |","+| ( ) PayPal                                                    |","+| ( ) Bank Transfer                                             |","++--------------------------------------------------------------+","+```","+","+| Attribute | Description |","+|---|---|","+| Layout | Radio button list, each option in a bordered card (same styling as shipping method radios) |","+| Default | First option (Credit Card) is pre-selected |","+| Live binding | On selection, the payment form below updates to show the relevant fields |","+","+**Credit Card form (shown when Credit Card is selected):**","+","+```","++--------------------------------------------------------------+","+| Card number     [4242 4242 4242 4242        ]                |","+| Cardholder name [John Doe                   ]                |","+| Expiry          [12/28     ]  CVC [123    ]                  |","++--------------------------------------------------------------+","+| [Pay now - 29.98 EUR]                                        |","++--------------------------------------------------------------+","+```","+","+| Field | Type | Required | Validation |","+|---|---|---|---|","+| Card number | Text (digits, auto-formatted with spaces every 4 digits) | Yes | 16 digits |","+| Cardholder name | Text | Yes | Max 255 |","+| Expiry | Text (MM/YY format) | Yes | Valid future date |","+| CVC | Text (3-4 digits) | Yes | 3-4 digits |","+","+**PayPal form (shown when PayPal is selected):**","+","+```","++--------------------------------------------------------------+","+| You will be redirected to complete your PayPal payment.       |","+| (mock: payment is processed instantly)                        |","+|                                                               |","+| [Pay with PayPal - 29.98 EUR]                                |","++--------------------------------------------------------------+","+```","+","+No additional form fields. The button text changes to \"Pay with PayPal - {total}\".","+","+**Bank Transfer form (shown when Bank Transfer is selected):**","+","+```","++--------------------------------------------------------------+","+| After placing your order, you will receive bank transfer      |","+| instructions. Your order will be held for 7 days while we     |","+| await your payment.                                           |","+|                                                               |","+| [Place order - 29.98 EUR]                                     |","++--------------------------------------------------------------+","+```","+","+No additional form fields. The button text changes to \"Place order - {total}\" (not \"Pay now\", since payment is deferred).","+"," | Element | Description |"," |---|---|","-| Order review | Brief summary of items, address, and shipping method |","-| Pay button | Primary, full-width, large text: \"Pay now\". Loading state: \"Processing...\" with spinner. Redirects to Stripe Checkout (server-side creates session and returns URL) |","-| PayPal | Optional PayPal button (if enabled) rendered below or alongside Stripe |","-| Alternative | Embedded Stripe Elements form (card number, expiry, CVC) depending on configuration |","+| Pay/Place button | Primary, full-width, large text. Loading state: \"Processing...\" with spinner. Calls `POST /checkouts/{id}/pay` with the selected method and card details (if credit card). |","+| Error handling | On payment decline (magic card number), display an inline error callout: \"Payment declined: {reason}\". The customer can correct details and retry. |","+| Success (card/paypal) | On successful instant capture, redirect to confirmation page. |","+| Success (bank_transfer) | On successful order placement, redirect to confirmation page (which shows bank transfer instructions). |"," "," ### 8.3 Order Summary Sidebar (Right Column)"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"4ed70088-f519-4408-bb9c-bdf3d9175d44"}
{"parentUuid":"3c1033c0-ceb1-4611-86e9-6eaf4cf9e201","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Gcin64usW3n52ZYh29uhSh","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the order confirmation page to handle different payment methods."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1326,"cache_read_input_tokens":120034,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1326},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wb1VRaZahRvdxZ9RKk","type":"assistant","uuid":"73acbb22-8933-4f70-8b47-d00329a144f7","timestamp":"2026-02-11T15:22:49.371Z"}
{"parentUuid":"73acbb22-8933-4f70-8b47-d00329a144f7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Gcin64usW3n52ZYh29uhSh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01J1zkxgmwB77FJPnzLjrtgL","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","old_string":"|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Visa ending in 4242             |\n|  123 Main St               |                                  |\n|  New York, NY 10001        |                                  |","new_string":"|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Credit Card ending in 4242      |\n|  123 Main St               |  (or: PayPal / Bank Transfer)    |\n|  New York, NY 10001        |                                  |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1326,"cache_read_input_tokens":120034,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1326},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wb1VRaZahRvdxZ9RKk","type":"assistant","uuid":"446bf03e-b70c-4168-8c74-ec7bf7a85b6b","timestamp":"2026-02-11T15:22:52.200Z"}
{"parentUuid":"446bf03e-b70c-4168-8c74-ec7bf7a85b6b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01J1zkxgmwB77FJPnzLjrtgL","toolUseID":"toolu_01J1zkxgmwB77FJPnzLjrtgL","timestamp":"2026-02-11T15:22:52.206Z","uuid":"81dff91e-8b3d-4a2f-81e6-6ed32a9c8511"}
{"parentUuid":"446bf03e-b70c-4168-8c74-ec7bf7a85b6b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01J1zkxgmwB77FJPnzLjrtgL","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md has been updated successfully."}]},"uuid":"f6aecc7a-b52a-4799-b77f-191a0b219edc","timestamp":"2026-02-11T15:22:52.206Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","oldString":"|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Visa ending in 4242             |\n|  123 Main St               |                                  |\n|  New York, NY 10001        |                                  |","newString":"|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Credit Card ending in 4242      |\n|  123 Main St               |  (or: PayPal / Bank Transfer)    |\n|  New York, NY 10001        |                                  |","originalFile":"# 04 - Storefront UI/UX Specification\n\n> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n| Templating | Blade templates with reusable storefront-prefixed components |\n| Accessibility | WCAG 2.2 AA compliance throughout |\n| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n\n---\n\n## Currency Formatting\n\nAll monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n\n| Setting | Value |\n|---------|-------|\n| Decimal separator | `.` (period) |\n| Thousands separator | `,` (comma) |\n| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n| Decimal places | Always 2 |\n| Free amounts | Display as `0.00 EUR` |\n| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n\n**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n\n**Examples:**\n- 2499 cents -> `24.99 EUR`\n- 0 cents -> `0.00 EUR`\n- 149900 cents -> `1,499.00 EUR`\n\n---\n\n## 1. Theme System Architecture\n\n### 1.1 View Organization\n\nThe storefront views are organized into the following categories:\n\n| Category | Purpose | Views |\n|---|---|---|\n| Layouts | Master page shell | app (master layout) |\n| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n| Checkout | Purchase flow | checkout index, order confirmation |\n| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n| Errors | Error states | 404, 503 |\n| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n\n### 1.2 Layout Hierarchy\n\nAll storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n\n### 1.3 Theme Settings\n\nTheme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n\n- Announcement bar visibility, text, link, and background color\n- Sticky header toggle\n- Home page section order and visibility\n- Color palette overrides (primary, secondary, accent)\n- Typography scale\n- Footer content and social links\n- Dark mode behavior (system preference, toggle, or forced)\n\n### 1.4 Section-Based Architecture\n\nEach page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n\n| Section | Description | Used On |\n|---|---|---|\n| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n| Featured Collections | Grid of collection cards | Home |\n| Featured Products | Grid of product cards (lazy-loaded) | Home |\n| Newsletter | Email signup form | Home, Footer |\n| Rich Text | Customizable content block | Home, Pages |\n\n### 1.5 Blade Components\n\nAll reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n\n---\n\n## 2. Base Layout\n\n### 2.1 Document Head\n\nThe document head includes:\n\n| Element | Description |\n|---|---|\n| Character encoding | UTF-8 |\n| Viewport | Width = device width, initial scale 1 |\n| Meta description | Page-specific, yielded from child templates |\n| Title | Page-specific title appended with the app name |\n| Stylesheets | Compiled CSS via Vite |\n| Scripts | Compiled JS via Vite |\n| Livewire styles | Injected by Livewire |\n| Language attribute | Set on the HTML element from application locale |\n| Scroll behavior | Smooth scrolling enabled globally |\n\n### 2.2 Skip Link\n\nA visually hidden \"Skip to main content\" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.\n\n**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.\n\n### 2.3 Announcement Bar\n\nOptional, controlled by theme settings. Renders a full-width bar above the header.\n\n| Attribute | Description |\n|---|---|\n| Position | Full-width, above the header |\n| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |\n| Text | Centered, small size, configurable content |\n| Link | Optional, underlined, opens in same tab |\n| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |\n| Height | Auto height with vertical padding |\n\n### 2.4 Header\n\nThe header serves as the primary navigation banner.\n\n**Desktop Layout (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |\n| Main Navigation | Center | Horizontal nav with links sourced from the \"main-menu\" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |\n| Search Icon | Right group | Button that opens the search modal |\n| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |\n| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |\n\n**Mobile Layout (below large breakpoint):**\n\n```\n+--------------------------------------------------------------+\n| [Hamburger]           [Logo]                            [Cart]|\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Hamburger menu | Left | Toggle button for mobile navigation drawer |\n| Logo | Center | Same as desktop but horizontally centered |\n| Cart icon | Right | Same as desktop |\n\n**Sticky Header (optional):**\n\nWhen enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.\n\n**Mobile Navigation Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Animation | Slides in from the left side |\n| Height | Full viewport height |\n| Backdrop | Semi-transparent dark overlay behind the drawer |\n| Close button | X icon at the top-right of the drawer |\n| Navigation items | Vertical stack with comfortable tap targets |\n| Submenus | Collapsible accordion pattern with chevron icons |\n| Account link | Positioned at the bottom of the navigation list |\n| Focus behavior | Focus is trapped within the drawer while open |\n| ARIA | Labeled as \"Mobile navigation\" |\n\n### 2.5 Main Content Area\n\nThe main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.\n\n### 2.6 Footer\n\nThe footer is a content-info landmark.\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n|                                                               |\n| [Social Icons]                                                |\n| [Copyright] [Payment Icons]                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n| Store info column | Store name, address (if configured), contact email |\n| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n| Background | Light gray (dark: very dark gray), top border separator |\n\n### 2.7 Cart Drawer (Livewire)\n\nThe cart drawer is always present in the layout as a global Livewire component. It listens for the \"cart-updated\" browser event to refresh and open. See Section 6 for full specification.\n\n### 2.8 Dark Mode\n\nDark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:\n\n| Mode | Implementation |\n|---|---|\n| System preference (default) | Follows OS-level dark mode preference automatically |\n| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |\n| Forced light/dark | Applied at server render time on the HTML element |\n\n**Color Token Patterns:**\n\n| Token | Light Mode | Dark Mode |\n|---|---|---|\n| Page background | White | Near-black |\n| Card/section background | Light gray | Very dark gray |\n| Heading text | Near-black | White |\n| Body text | Medium gray | Light gray |\n| Borders | Light gray | Medium-dark gray |\n| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |\n\n---\n\n## 3. Home Page (GET /)\n\nThe home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:\n\n1. Hero Banner\n2. Featured Collections\n3. Featured Products\n4. Newsletter Signup\n5. Rich Text\n\n### 3.1 Hero Banner Section\n\n**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.\n\n**Desktop (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n|                                                               |\n|  [Background Image / Gradient]                                |\n|                                                               |\n|       [Heading - very large, bold]                            |\n|       [Subheading - medium, regular weight]                   |\n|       [CTA Button - primary, large]                           |\n|                                                               |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |\n| Content | Centered vertically and horizontally, white text |\n| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |\n| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |\n| CTA | Styled as primary button, large. Links to a configured collection or product |\n| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |\n| Padding | Generous vertical and horizontal padding |\n\n**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.\n\n**Accessibility:**\n\n- If background is an image, it is decorative (the content provides meaning)\n- CTA button has descriptive text (e.g., \"Shop the summer collection\")\n\n### 3.2 Featured Collections Section\n\n**Layout:** Section with heading and grid of collection cards.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, large, bold, centered, with generous bottom margin |\n| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |\n| Count | 2 to 4 collections, configurable in theme settings |\n\n**Collection Card:**\n\n```\n+---------------------------+\n|  [Collection Image]       |\n|  3:4 aspect ratio         |\n|  cover-fit, rounded       |\n|                           |\n|  [Title overlay at bottom]|\n|  [Shop now ->]            |\n+---------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |\n| Title | White text overlaid at bottom of image, semi-bold, medium-large size |\n| \"Shop now\" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |\n| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |\n\n**Accessibility:**\n\n- Each card link has the collection title as accessible name\n- Images have alt text matching the collection title\n\n### 3.3 Featured Products Section\n\n**Livewire Component:** FeaturedProducts\n\nThis section uses Livewire for lazy loading to improve initial page load.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, same styling as featured collections heading |\n| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |\n| Products | 4 to 8, configurable. Sourced from a configured collection or \"best sellers\" logic |\n| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |\n\nEach product uses the product-card component (see Section 16).\n\n### 3.4 Newsletter Signup Section\n\n**Livewire Component:** NewsletterSignup\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n|  [Heading: \"Stay in the loop\"]                                |\n|  [Subtext: \"Subscribe for exclusive offers and updates.\"]     |\n|  [Email input] [Subscribe button]                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Light gray (dark mode: darker gray), generous vertical padding |\n| Content | Centered, constrained to a comfortable width |\n| Heading | H2, medium-large, bold |\n| Subtext | Muted color, small top margin |\n| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |\n| Input | Email type, placeholder \"Enter your email\", required |\n| Button | Primary style, \"Subscribe\" text |\n| Success state | Replaces form with \"Thanks for subscribing!\" message |\n| Error state | Inline error below input in red, small text |\n| Loading state | Button text changes to \"Subscribing...\" during submission |\n\n**Accessibility:**\n\n- Label associated with email input (visually hidden if using placeholder)\n- Success/error messages in a polite live region for screen readers\n\n### 3.5 Rich Text Section\n\nSimple section rendering customizable content from theme settings.\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained to comfortable reading width, centered, vertical padding |\n| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |\n| Dark mode | Inverted prose styling |\n\n---\n\n## 4. Collection Page (GET /collections/{handle})\n\n**Livewire Component:** CollectionPage\n\n### 4.1 Collection Header\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > Collections > {Collection Title} |\n| Title | H1, large, bold |\n| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |\n| Spacing | Generous vertical padding |\n\n### 4.2 Toolbar\n\nHorizontal bar between the header and the product grid.\n\n**Desktop:**\n\n```\n+--------------------------------------------------------------+\n| [Filter toggle button]          [X products] [Sort dropdown]  |\n+--------------------------------------------------------------+\n```\n\n**Mobile:**\n\n```\n+--------------------------------------------------------------+\n| [Filter button]                           [Sort dropdown]     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Product count | Small muted text, e.g., \"24 products\" |\n| Sort dropdown | Select element with live Livewire binding |\n\n**Sort Options:**\n\n| Option | Description |\n|---|---|\n| Featured | Default sort order |\n| Price: Low to High | Ascending by price |\n| Price: High to Low | Descending by price |\n| Newest | Descending by creation date |\n| Best Selling | Descending by sales count |\n\n### 4.3 Filter Sidebar\n\n**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.\n**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by \"Filter\" button with filter icon.\n\n**Filter Groups:**\n\nEach filter group is a collapsible section with a heading and toggle chevron.\n\n| Filter Group | Type | Binding | Description |\n|---|---|---|---|\n| Availability | Single checkbox | Live | \"In stock\" toggle |\n| Price Range | Two number inputs | Live with 500ms debounce | \"Min\" and \"Max\" inputs with currency symbol prefix, displayed side by side |\n| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |\n| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |\n\n**Clear All:**\n\n- Link at the top of the filter sidebar: \"Clear all filters\"\n- Only visible when at least one filter is active\n- Small, blue, interactive text\n\n**Active Filter Pills:**\n\n- Displayed above the product grid (below toolbar) when filters are active\n- Each pill: rounded tag with filter value text and a remove (X) button\n- \"Clear all\" link at the end of the pills row\n\n**Mobile Filter Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Position | Full-height overlay from left side |\n| Header | \"Filters\" title + close button (X) |\n| Content | Same filter groups as desktop, stacked vertically |\n| Footer | Sticky \"Apply\" button at bottom that closes the drawer |\n| Clear | \"Clear all\" link in header area |\n| Focus | Trapped within drawer while open |\n\n### 4.4 Product Grid\n\n| Attribute | Description |\n|---|---|\n| Layout | Fills remaining width next to the sidebar |\n| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |\n| Loading state | Subtle opacity reduction on the grid while results update |\n\n### 4.5 Product Card (Component)\n\nThe product card is a reusable component used on collection pages, search results, and featured products sections.\n\n```\n+---------------------------+\n|  [Product Image]          |\n|  1:1 aspect ratio         |\n|  cover-fit, rounded       |\n|  (hover: show 2nd image)  |\n|                           |\n|  [Sale badge]  [Sold out] |\n+---------------------------+\n|  [Product Title]          |\n|  [Price]  [Compare price] |\n|  [Quick Add / Options]    |\n+---------------------------+\n```\n\n**Image Behavior:**\n\n| Attribute | Description |\n|---|---|\n| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |\n| Primary image | Cover-fit, fills the container |\n| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |\n| Fallback | If no image, show a centered shopping bag placeholder icon |\n| Loading | Lazy-loaded |\n\n**Badges:**\n\n| Badge | Condition | Position |\n|---|---|---|\n| \"Sale\" | compare_at_amount > price_amount | Top-left of image, stacked vertically |\n| \"Sold out\" | All variants out of stock with deny policy | Top-left of image, stacked vertically |\n\n**Text Content:**\n\n| Element | Description |\n|---|---|\n| Title | H3 (or configurable heading level), small semi-bold text. Truncated to 2 lines |\n| Price | Current price in small semi-bold text. Compare-at price shown with strikethrough if applicable |\n| Quick Add | Single variant: \"Add to cart\" button. Multiple variants: \"Choose options\" link text. Hidden by default on desktop (shown on hover), always visible on mobile |\n\n**Accessibility:**\n\n- Card link wraps the entire card; title serves as the accessible name\n- Badges have screen reader labels (e.g., \"On sale\")\n- Price changes announced via parent live region when filtering changes results\n\n### 4.6 Pagination\n\n| Attribute | Description |\n|---|---|\n| Style | Numbered pagination with previous/next arrow buttons |\n| Current page | Highlighted with primary color background and white text |\n| Other pages | Muted text, hover background |\n| Disabled | Reduced opacity and not-allowed cursor on previous (at first page) or next (at last page) |\n| Truncation | Ellipsis for large page counts |\n| Mobile | Simplified: previous/next only with current page indicator |\n\n### 4.7 Empty State\n\nWhen no products match the current filters:\n\n```\n+--------------------------------------------------------------+\n|  [Search icon - large, muted]                                 |\n|  \"No products found\"                                          |\n|  \"Try adjusting your filters or browse our full collection.\"  |\n|  [Clear filters button]                                       |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Icon | Large, muted search icon |\n| Heading | Medium, semi-bold |\n| Subtext | Muted, descriptive guidance |\n| Button | Secondary style, \"Clear filters\" |\n| Position | Centered in the grid area with generous vertical padding |\n\n---\n\n## 5. Product Page (GET /products/{handle})\n\n**Livewire Component:** ProductPage\n\n### 5.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column, 50/50 split. Left column (images) is sticky while the right column (product info) scrolls.\n\n**Mobile:** Single column, stacked. Images first, then product info.\n\n```\nDesktop:\n+-------------------------------+-------------------------------+\n|  [Image Gallery]              |  [Product Info]               |\n|  (sticky while scrolling)     |  (scrollable)                 |\n+-------------------------------+-------------------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Image Gallery - horizontal scroll]                          |\n+--------------------------------------------------------------+\n|  [Product Info - full width]                                  |\n+--------------------------------------------------------------+\n```\n\nContainer constrained to standard max-width with horizontal padding and vertical spacing.\n\n### 5.2 Image Gallery (Left Column)\n\n**Desktop:**\n\n```\n+-------------------------------+\n|                               |\n|  [Main Image - large]         |\n|  1:1 aspect ratio, rounded    |\n|                               |\n+-------------------------------+\n| [Thumb] [Thumb] [Thumb] ...  |\n+-------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Main image | Square aspect ratio, large rounded corners, cover-fit. Gray placeholder background |\n| Zoom on hover | Optional (theme setting): image scales to 150% and follows cursor position |\n| Thumbnail strip | Horizontal row with overflow scroll, small gap between thumbnails |\n| Each thumbnail | 64x64px, rounded, cover-fit, pointer cursor. Active thumbnail has a colored border |\n| Variant images | When a variant is selected that has specific images, those images are shown |\n\n**Mobile:**\n\n| Attribute | Description |\n|---|---|\n| Gallery | Horizontal scrollable row with snap-scroll behavior |\n| Each image | Full-width, square aspect ratio, rounded, cover-fit |\n| Scroll indicators | Row of dots below the gallery. Active dot is dark (light in dark mode), inactive dots are gray |\n\n**Accessibility:**\n\n| Requirement | Description |\n|---|---|\n| Alt text | Main image has descriptive alt text from product media data |\n| Thumbnails | Rendered as buttons with labels \"View image X of Y\" and current state indicator |\n| Region | Gallery wrapped in a labeled region (\"Product images\") |\n\n### 5.3 Product Info (Right Column)\n\n#### Breadcrumbs\n\nHome > {Primary Collection} > {Product Title}. Small, muted text with separator characters.\n\n#### Product Title\n\nH1, large, bold.\n\n#### Price Display\n\n| Element | Description |\n|---|---|\n| Current price | Large, bold |\n| Compare-at price | Medium, muted, strikethrough (shown when set and higher than current price) |\n| Sale badge | Inline badge next to the price |\n| Currency | Formatted based on the store's default currency |\n| Live updates | Price container is a live region, so price changes when variant changes are announced to screen readers |\n\n#### Variant Selector\n\nFor each product option (e.g., Size, Color), render a selector group.\n\n**Radio button style (for options with 6 or fewer values):**\n\n```\nSize\n[S] [M] [L] [XL] [XXL]\n```\n\n| State | Visual Description |\n|---|---|\n| Available | Bordered pill shape, pointer cursor, border darkens on hover |\n| Selected | Primary color border with subtle background tint and focus ring |\n| Unavailable | Muted border, muted text, strikethrough, reduced opacity, not-allowed cursor |\n\n**Select dropdown style:** Used when the option has more than 6 values.\n\n**Color swatches (when option name is \"Color\"):**\n\n| Attribute | Description |\n|---|---|\n| Shape | Circular, 32x32px |\n| Color | Determined by value name mapped to a color lookup or inline style |\n| Selected state | Ring with offset around the swatch |\n| Tooltip | Shows color name on hover |\n\n**Livewire behavior:**\n\n- Selected options are a key-value map (e.g., Size = M, Color = Red)\n- Selected variant is computed by matching all selected options to the variant option values\n- On change: updates price, stock messaging, available images, and add-to-cart availability\n\n#### Stock Messaging\n\nDisplayed below the variant selector.\n\n| Condition | Message | Visual Style |\n|---|---|---|\n| In stock, quantity > 10 | \"In stock\" | Green text with check icon |\n| In stock, quantity <= 10 | \"Only X left in stock\" | Amber text with warning icon |\n| Out of stock, deny policy | \"Out of stock\" | Red text with X icon |\n| Out of stock, continue policy | \"Available on backorder\" | Blue text with info icon |\n\nSmall text, icon + text inline. Wrapped in a live region for screen reader announcements on variant change.\n\n#### Quantity Selector\n\nUses the quantity-selector component.\n\n```\n[-] [3] [+]\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Inline flex, bordered, rounded |\n| Decrease button | \"-\" icon, 40x40px. Disabled at minimum (1) |\n| Input | Centered number input, 56px wide, 40px tall. Min = 1, Max = available stock (deny policy) or unlimited (continue policy) |\n| Increase button | \"+\" icon, same size. Disabled at maximum |\n| Disabled state | Reduced opacity, not-allowed cursor |\n| Labels | Input labeled \"Quantity\", buttons labeled \"Decrease quantity\" / \"Increase quantity\" |\n\n#### Add to Cart Button\n\n| Attribute | Description |\n|---|---|\n| Width | Full width |\n| Style | Primary button: blue background, white text, semi-bold, generous padding, rounded |\n| Text | \"Add to cart\" |\n| Loading state | Text changes to \"Adding...\" with spinner icon. Button disabled during loading |\n| Sold out state | Gray background, muted text, not-allowed cursor. Text: \"Sold out\" |\n| Spacing | Generous top margin |\n\n**Livewire action (addToCart):**\n\n1. Validates variant is selected and in stock\n2. Adds to cart (creates cart if none exists in session)\n3. Dispatches \"cart-updated\" browser event\n4. Cart drawer opens automatically\n\n#### Product Description\n\n- Separated from above content by a horizontal rule with generous vertical spacing\n- Rendered HTML styled with prose/typography styling\n\n#### Tags\n\n- Displayed below the description (if tags exist)\n- Horizontal wrapping row with gap\n- Each tag: rounded pill, gray background, small text\n\n### 5.4 Accessibility Summary\n\n| Requirement | Implementation |\n|---|---|\n| Variant radio buttons | Properly labeled with labels and grouped with fieldset/legend |\n| Live regions | Price display, stock messaging, add-to-cart feedback |\n| Image alt text | All images have meaningful alt text |\n| Keyboard | Tab through thumbnails, variant options, quantity controls, and add-to-cart |\n| Focus indicators | Visible ring styles on all interactive elements |\n\n---\n\n## 6. Cart Drawer\n\n**Livewire Component:** CartDrawer\n\n### 6.1 Trigger and Animation\n\n| Attribute | Description |\n|---|---|\n| Trigger | \"cart-updated\" browser event (from add-to-cart actions), or clicking the cart icon in the header |\n| Animation | Slides in from the right side of the viewport. Transforms from fully off-screen to visible |\n| Backdrop | Fixed full-screen semi-transparent dark overlay. Click to close |\n| Panel | Fixed, right-aligned, full height, full width on small screens or 384px on larger screens. White background (dark mode: near-black), shadow |\n| Body | Scroll locked when drawer is open |\n\n### 6.2 Drawer Header\n\n```\n+--------------------------------------+\n| Your Cart (3)                    [X] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Heading | H2, semi-bold, medium-large text |\n| Item count | In parentheses after heading |\n| Close button | X icon, labeled \"Close cart\" |\n| Layout | Flex row, space-between, with horizontal padding and bottom border |\n\n### 6.3 Cart Line Items\n\nScrollable list area occupying the space between header and totals.\n\nEach line item:\n\n```\n+--------------------------------------+\n| [Thumb]  Title                       |\n|          Variant: Size M / Blue      |\n|          [-] [2] [+]       $49.98    |\n|                             [Remove] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Thumbnail | 64x64px, rounded, cover-fit, non-shrinking |\n| Title | Small semi-bold text, truncated to 1 line |\n| Variant info | Extra-small muted text showing selected options |\n| Quantity stepper | Compact version of quantity-selector (smaller buttons, 32x32px) |\n| Line total | Small semi-bold text |\n| Remove button | Trash icon, muted color, turns red on hover. Labeled \"Remove {item title} from cart\" |\n| Separation | Bottom border between items, vertical padding per item |\n| Loading | Opacity reduction on the line item during quantity update |\n\n### 6.4 Discount Code Section\n\nBelow the line items list, above totals.\n\n**No discount applied:**\n\n```\n+--------------------------------------+\n| [Discount code input] [Apply]        |\n+--------------------------------------+\n```\n\n**Discount applied:**\n\n```\n+--------------------------------------+\n| SUMMER20 (-15%)              [Remove]|\n+--------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Input | Small text, placeholder \"Discount code\" |\n| Apply button | Secondary style, small |\n| Success state | Green text showing the code and discount type, with a remove button |\n| Error state | Small red text below, e.g., \"Invalid discount code\" or \"This code has expired\" |\n| Loading state | Button text changes to \"Applying...\" |\n\n### 6.5 Totals Section\n\n```\n+--------------------------------------+\n| Subtotal                     $149.97 |\n| Discount (SUMMER20)          -$22.50 |\n| ------------------------------------ |\n| Estimated total              $127.47 |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Position | Sticky at the bottom of the drawer, with top border |\n| Each line | Flex row, justified space-between, small text |\n| Discount line | Only shown when discount applied, green text |\n| Total | Slightly larger text, semi-bold |\n| Note | \"Shipping and taxes calculated at checkout\" in extra-small muted text below totals |\n\n### 6.6 Action Buttons\n\n| Button | Description |\n|---|---|\n| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |\n| Continue shopping | Centered text link below checkout button. Small muted text, hover to darker. Closes the drawer |\n\n### 6.7 Empty Cart State\n\n```\n+--------------------------------------+\n|                                      |\n|  [Shopping bag icon - large]         |\n|  \"Your cart is empty\"                |\n|  [Continue shopping button]          |\n|                                      |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Centered vertically and horizontally in the drawer content area |\n| Icon | Large, very muted shopping bag icon |\n| Text | Muted, with top margin |\n| Button | Secondary style, closes the drawer |\n\n### 6.8 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Role | Dialog with modal behavior, labeled \"Shopping cart\" |\n| Focus trap | Tab/Shift+Tab cycles through drawer elements only |\n| On open | Focus moves to the close button or first interactive element |\n| On close | Focus returns to the element that triggered the drawer (cart icon) |\n| Escape key | Closes the drawer |\n| Feedback | All quantity changes and removals announced via polite live region |\n\n---\n\n## 7. Full Cart Page (GET /cart)\n\n**Livewire Component:** CartPage\n\nSame functionality as the cart drawer but in a full-page layout.\n\n### 7.1 Desktop Layout\n\n```\n+--------------------------------------------------------------+\n|  Your Cart                                                    |\n+--------------------------------------------------------------+\n|  Image | Product      | Price   | Quantity | Total   |       |\n|  ------|------------- |---------|----------|---------|-------|\n|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n|        | Size: M      |         |          |         |       |\n|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n|        | Color: Blue  |         |          |         |       |\n+--------------------------------------------------------------+\n|                                            Subtotal: $84.97   |\n|                              [Discount code] [Apply]          |\n|                                            Discount: -$12.75  |\n|                                            Total:    $72.22   |\n|                                                               |\n|                                            [Checkout button]  |\n|                                            [Continue shopping]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n| Title | H1, large bold, generous bottom margin |\n| Desktop layout | Proper table with header row and body |\n| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n\n### 7.2 Totals and Actions\n\n- Right-aligned on desktop, full-width on mobile\n- Same discount code input/apply functionality as drawer\n- Same totals breakdown\n- \"Checkout\" button: primary, aligned right\n- \"Continue shopping\" link: below checkout button\n\n### 7.3 Empty State\n\nSame as drawer empty state but centered in the page content area with more vertical padding.\n\n---\n\n## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment Method & Pay\n\nAll payment is handled onsite via the Mock PSP. No external redirects, no iframes, no third-party scripts.\n\n**Payment method selection (radio buttons):**\n\n```\n+--------------------------------------------------------------+\n| Select a payment method                                       |\n|                                                               |\n| (o) Credit Card                                               |\n| ( ) PayPal                                                    |\n| ( ) Bank Transfer                                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Layout | Radio button list, each option in a bordered card (same styling as shipping method radios) |\n| Default | First option (Credit Card) is pre-selected |\n| Live binding | On selection, the payment form below updates to show the relevant fields |\n\n**Credit Card form (shown when Credit Card is selected):**\n\n```\n+--------------------------------------------------------------+\n| Card number     [4242 4242 4242 4242        ]                |\n| Cardholder name [John Doe                   ]                |\n| Expiry          [12/28     ]  CVC [123    ]                  |\n+--------------------------------------------------------------+\n| [Pay now - 29.98 EUR]                                        |\n+--------------------------------------------------------------+\n```\n\n| Field | Type | Required | Validation |\n|---|---|---|---|\n| Card number | Text (digits, auto-formatted with spaces every 4 digits) | Yes | 16 digits |\n| Cardholder name | Text | Yes | Max 255 |\n| Expiry | Text (MM/YY format) | Yes | Valid future date |\n| CVC | Text (3-4 digits) | Yes | 3-4 digits |\n\n**PayPal form (shown when PayPal is selected):**\n\n```\n+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Pay with PayPal - {total}\".\n\n**Bank Transfer form (shown when Bank Transfer is selected):**\n\n```\n+--------------------------------------------------------------+\n| After placing your order, you will receive bank transfer      |\n| instructions. Your order will be held for 7 days while we     |\n| await your payment.                                           |\n|                                                               |\n| [Place order - 29.98 EUR]                                     |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Place order - {total}\" (not \"Pay now\", since payment is deferred).\n\n| Element | Description |\n|---|---|\n| Pay/Place button | Primary, full-width, large text. Loading state: \"Processing...\" with spinner. Calls `POST /checkouts/{id}/pay` with the selected method and card details (if credit card). |\n| Error handling | On payment decline (magic card number), display an inline error callout: \"Payment declined: {reason}\". The customer can correct details and retry. |\n| Success (card/paypal) | On successful instant capture, redirect to confirmation page. |\n| Success (bank_transfer) | On successful order placement, redirect to confirmation page (which shows bank transfer instructions). |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.\n\n```\n+--------------------------------------------------------------+\n|  [Checkmark icon - green circle]                              |\n|  \"Thank you for your order!\"                                  |\n|  Order #1042                                                  |\n|  \"We've sent a confirmation to you@email.com\"                 |\n+--------------------------------------------------------------+\n|  Order Summary                                                |\n|  [Items list with thumbnails]                                 |\n+--------------------------------------------------------------+\n|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Visa ending in 4242             |\n|  123 Main St               |                                  |\n|  New York, NY 10001        |                                  |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  [Continue shopping button]  [View order - if logged in]      |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained to a comfortable width, centered, generous vertical padding |\n| Success icon | Large green checkmark in a circle, centered |\n| Heading | H1, large bold, centered, generous top margin |\n| Order number | Medium muted text, centered |\n| Email notice | Small muted text, centered |\n\n### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Totals | Right-aligned summary |\n\n### 9.3 Actions\n\n| Button | Description |\n|---|---|\n| Continue shopping | Primary, links to home page |\n| View order | Link to account order detail page (logged-in customers only) |\n| Layout | Centered row with gap |\n\n---\n\n## 10. Customer Account Pages\n\nAll account pages require authentication. Unauthenticated access redirects to the login page.\n\n### 10.1 Login (GET /account/login)\n\n**Livewire Component:** Login\n\n```\n+--------------------------------------------------------------+\n|  Log in to your account                                       |\n|                                                               |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Forgot password? link]                                      |\n|  [Log in button]                                              |\n|                                                               |\n|  Don't have an account? [Create one]                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, autocomplete for current password |\n| Forgot password | Small blue link, right-aligned |\n| Submit button | Primary, full-width, \"Log in\" |\n| Error messages | Inline below fields in small red text |\n| General error | Invalid credentials shown in a callout/alert above the form |\n| Register link | \"Create one\" link to registration page |\n\n### 10.2 Register (GET /account/register)\n\n**Livewire Component:** Register\n\n```\n+--------------------------------------------------------------+\n|  Create an account                                            |\n|                                                               |\n|  [Name input]                                                 |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Confirm password input]                                     |\n|  [ ] Subscribe to marketing emails                            |\n|  [Create account button]                                      |\n|                                                               |\n|  Already have an account? [Log in]                            |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Name | Text type, required, autocomplete for name |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, minimum 8 characters, autocomplete for new password |\n| Confirm password | Password type, required, autocomplete for new password |\n| Marketing checkbox | Checkbox with label \"Subscribe to marketing emails\" |\n| Submit button | Primary, full-width, \"Create account\" |\n| On success | Redirect to account dashboard |\n\n### 10.3 Account Dashboard (GET /account)\n\n```\n+--------------------------------------------------------------+\n|  Welcome back, Jane!                                          |\n+--------------------------------------------------------------+\n|  [Orders card]     [Addresses card]     [Log out card]        |\n+--------------------------------------------------------------+\n|  Recent Orders                                                |\n|  # | Date       | Status  | Total   | Action                 |\n|  --|------------|---------|---------|--------                 |\n|  1042 | Jan 15  | Paid    | $98.70  | View                   |\n|  1041 | Jan 10  | Shipped | $45.00  | View                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained max-width, centered, with padding |\n| Welcome heading | H1, medium-large bold |\n| Quick links | Responsive grid: 1 column on mobile, 3 on tablet+. Each card has border, rounded corners, padding, hover shadow |\n| Orders card | Order icon, \"Order history\", \"View all your orders\" |\n| Addresses card | Location icon, \"Addresses\", \"Manage your addresses\" |\n| Log out card | Logout icon, \"Log out\", submits POST logout form |\n| Recent orders | Table showing last 5 orders. Status shown as badge component |\n\n### 10.4 Order History (GET /account/orders)\n\n| Attribute | Description |\n|---|---|\n| Heading | H1, \"Order History\" |\n| Table columns | Order (link), Date, Status (badge), Total, Action (\"View\" link) |\n| Status badge colors | Pending: yellow. Paid: green. Fulfilled: blue. Cancelled: gray. Refunded: red |\n| Mobile | Card-based layout instead of table. Each card shows order number, date, status, and total |\n| Pagination | Standard pagination component |\n\n### 10.5 Order Detail (GET /account/orders/{orderNumber})\n\n```\n+--------------------------------------------------------------+\n|  Order #1042                      Status: [Paid] [Fulfilled] |\n|  Placed on January 15, 2026                                  |\n+--------------------------------------------------------------+\n|  Items                                                        |\n|  [Thumb] Product Title x2              $49.98                 |\n|          Size: M                                              |\n+--------------------------------------------------------------+\n|  Shipping Address    |  Billing Address   | Payment           |\n|  Jane Doe            |  Same as shipping  | Visa ****4242     |\n|  123 Main St         |                    |                   |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Discount        -$0.00                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  Fulfillment                                                  |\n|  Shipped via UPS - 1Z999AA10123456784                         |\n|  [Track shipment ->]                                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Account > Orders > #1042 |\n| Header | Order number and status badges |\n| Date | Small muted text |\n| Items | Table with thumbnail, title + variant info, quantity, line total |\n| Info grid | Three columns on desktop (shipping, billing, payment), stacked on mobile |\n| Totals | Summary breakdown |\n| Fulfillment | Only shown if fulfillments exist. Shows tracking company, number, and link to tracking URL (opens in new tab safely) |\n\n### 10.6 Address Book (GET /account/addresses)\n\n**Livewire Component:** AddressBook\n\n```\n+--------------------------------------------------------------+\n|  Your Addresses                          [+ Add new address]  |\n+--------------------------------------------------------------+\n|  [Default badge]              |                               |\n|  Jane Doe                     |  Jane Doe                     |\n|  123 Main St                  |  456 Oak Ave                  |\n|  New York, NY 10001           |  Chicago, IL 60601            |\n|  United States                |  United States                |\n|  [Edit] [Delete]              |  [Edit] [Delete] [Set default]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Grid | 1 column on mobile, 2 on tablet, 3 on desktop |\n| Each card | Bordered, rounded, with padding. Default address has a primary-colored border and \"Default\" badge |\n| Content | Formatted address lines |\n| Actions | Edit and Delete text buttons, with \"Set as default\" on non-default addresses |\n| Add new | Opens modal with address-form component |\n| Edit | Opens same modal pre-filled |\n| Delete | Confirmation dialog before deleting |\n\n---\n\n## 11. Search\n\n### 11.1 Search Modal\n\n**Livewire Component:** SearchModal\n\nTriggered by clicking the search icon in the header.\n\n```\n+--------------------------------------------------------------+\n|  [Search input with icon]                           [X close] |\n+--------------------------------------------------------------+\n|  Products                                                     |\n|  [Thumb] Product Title            $24.99                      |\n|  [Thumb] Product Title            $34.99                      |\n|                                                               |\n|  Collections                                                  |\n|  Summer Collection                                            |\n|  Winter Collection                                            |\n|                                                               |\n|  [View all X results ->]                                      |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Overlay | Full-screen semi-transparent dark backdrop |\n| Modal | Centered, moderate max-width, rounded corners, shadow |\n| Search input | Large text, search icon prefix, autofocus on open. Live binding with 300ms debounce |\n| Results | Appear below input as user types. Grouped by type: \"Products\" and \"Collections\" headings |\n| Product results | Thumbnail, title, price. Clickable to product page |\n| Collection results | Title only. Clickable to collection page |\n| Max results | 5 per category in the modal |\n| View all | Link at the bottom navigating to the full search results page |\n| Loading | Skeleton lines while fetching |\n| No results | \"No results for '{query}'\" message |\n| Keyboard | Escape closes, arrow keys navigate results, Enter selects highlighted result |\n| Focus | Trapped within the modal |\n| ARIA | Dialog role, modal, labeled \"Search\". Results list as listbox with option items |\n\n### 11.2 Search Results Page (GET /search?q=)\n\n**Livewire Component:** SearchResults\n\nSame layout as the collection page (Section 4) with the following differences:\n\n| Difference | Description |\n|---|---|\n| Heading | H1, \"X results for '{query}'\" or \"Search results for '{query}'\" |\n| Breadcrumbs | Home > Search results |\n| Filters | Same filter sidebar (availability, price, type, vendor) |\n| Sort | Same sort dropdown |\n| Product grid | Same product card component |\n| Pagination | Same pagination |\n| Empty state | \"No results found for '{query}'. Try a different search term.\" with a search input to try again |\n\n---\n\n## 12. Content Pages (GET /pages/{handle})\n\n### 12.1 Layout\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > {Page Title} |\n| Title | H1, large bold |\n| Body | Rendered HTML from body_html, styled with prose/typography styling, constrained to comfortable reading width (configurable to full width via theme settings) |\n| Container | Standard max-width, centered, with padding |\n\n---\n\n## 13. Error Pages\n\n### 13.1 404 Page\n\n```\n+--------------------------------------------------------------+\n|  [404 - large, bold, very muted]                              |\n|  \"Page not found\"                                             |\n|  \"The page you're looking for doesn't exist or has been       |\n|   moved.\"                                                     |\n|                                                               |\n|  [Search input]                                               |\n|  [Go to home page button]                                     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered vertically and horizontally |\n| 404 text | Very large, bold, extremely muted (barely visible as a background element) |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Search | Simple input + button, submits to search page |\n| Home link | Secondary button |\n\n### 13.2 503 / Maintenance Page\n\n```\n+--------------------------------------------------------------+\n|  [Store logo]                                                 |\n|  \"We'll be back soon\"                                         |\n|  \"We're currently performing maintenance. Please check back   |\n|   shortly.\"                                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered column |\n| Logo | Standard height, centered |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Style | Consistent with the store theme (colors, fonts) |\n\n---\n\n## 14. Responsive Breakpoints\n\nDefault breakpoints used across the storefront:\n\n| Breakpoint | Min-width | Typical Devices |\n|---|---|---|\n| Base (none) | 0px | Mobile phones (portrait) |\n| Small | 640px | Mobile phones (landscape), small tablets |\n| Medium | 768px | Tablets (portrait) |\n| Large | 1024px | Tablets (landscape), small desktops |\n| Extra-large | 1280px | Desktops |\n\n### Key Responsive Behaviors\n\n| Component | Mobile (base) | Tablet (medium) | Desktop (large+) |\n|---|---|---|---|\n| Navigation | Hamburger + drawer | Hamburger + drawer | Horizontal nav bar |\n| Product grid | 2 columns | 3 columns | 3-4 columns |\n| Product page | Stacked (images, then info) | Stacked | Side-by-side 50/50 |\n| Cart drawer | Full-screen overlay | 384px sidebar | 384px sidebar |\n| Checkout | Single column, summary collapsed | Single column | Two columns (form + summary) |\n| Footer | Stacked columns | 2-column grid | 3-4 column grid |\n| Collection filters | Drawer overlay | Drawer overlay | Persistent sidebar |\n| Account tables | Card-based layout | Card-based layout | Table layout |\n\n### Touch Considerations\n\n| Consideration | Requirement |\n|---|---|\n| Tap targets | Minimum 44x44px for all interactive elements on mobile |\n| Hover effects | Only applied on devices with hover capability |\n| Swipe gestures | Horizontal swipe on product images (mobile gallery), cart drawer close |\n\n---\n\n## 15. Accessibility Checklist\n\n### Global (All Pages)\n\n| Requirement | Description |\n|---|---|\n| Skip link | \"Skip to main content\" link, first element in body |\n| Landmark roles | Banner for header, main for content, contentinfo for footer, labeled nav elements |\n| Heading hierarchy | Single H1 per page, logical H2-H6 nesting |\n| Focus indicators | Visible ring on all interactive elements with offset |\n| Color contrast | Minimum 4.5:1 for normal text, 3:1 for large text (WCAG AA) |\n| Keyboard navigation | All interactive elements reachable via Tab, operable via Enter/Space |\n| Screen reader text | Visually hidden text for icon-only buttons (close, cart, search) |\n| Language | Lang attribute on HTML element |\n| Images | All images have alt attributes; decorative images marked appropriately |\n\n### Page-Specific Accessibility\n\n| Page | Requirements |\n|---|---|\n| Collection Page | Live region for product count/grid on filter change. Filter sidebar labeled as navigation. Sort dropdown has associated label |\n| Product Page | Variant selector uses fieldset/legend grouping. Price and stock in live regions. Image gallery thumbnails as buttons with current state |\n| Cart Drawer | Dialog role with modal behavior, labeled \"Shopping cart\". Focus trap. Quantity changes in live region |\n| Checkout | Form validation with describedby linking to errors, invalid state on fields. Focus management on step transitions. Shipping radios in fieldset/legend. Totals in live region |\n| Search Modal | Dialog role with modal behavior. Results as listbox with option items. Active descendant tracking for keyboard navigation |\n| Account Pages | All form inputs labeled. Tables use proper header markup with column scope. Status badges readable by text (not color-only) |\n\n### Keyboard Interactions\n\n| Component | Key | Action |\n|---|---|---|\n| Hamburger menu | Enter/Space | Toggle open/close |\n| Mobile nav drawer | Escape | Close |\n| Dropdown menus | Arrow Up/Down | Navigate items |\n| Dropdown menus | Escape | Close |\n| Cart drawer | Escape | Close |\n| Search modal | Escape | Close |\n| Search results | Arrow Up/Down | Navigate results |\n| Search results | Enter | Select highlighted result |\n| Variant radio buttons | Arrow Left/Right | Navigate options |\n| Quantity input | Arrow Up/Down | Increment/decrement |\n| Modals/dialogs | Tab/Shift+Tab | Cycle focus within (trap) |\n\n---\n\n## 16. Component Library\n\n### storefront-layout\n\nMaster layout wrapper.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| title | string | App name from config | Page title |\n| metaDescription | string | Empty | Meta description tag |\n\n### storefront-product-card\n\nRenders a single product card in grid contexts.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| product | Product model | Required | Product with variants, media, and inventory eager-loaded |\n| headingLevel | string | h3 | Heading tag for the product title |\n| showQuickAdd | boolean | true | Whether to show the quick add / choose options action |\n\n**Eager-load requirements:** product.variants.inventoryItem, product.media (sorted by position).\n\n### storefront-price\n\nDisplays a formatted price with optional compare-at price.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| amount | integer | Required | Price in minor units (cents) |\n| currency | string | USD | ISO 4217 currency code |\n| compareAtAmount | integer or null | null | Original price in minor units for sale display |\n\n**Rendering:** Formats amount using Laravel's Number currency helper. If compareAtAmount is set and greater than amount, shows compare-at price with strikethrough styling and a \"Sale\" badge.\n\n### storefront-badge\n\nRenders a styled badge/tag.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| text | string | Required | Badge text |\n| variant | string | default | Visual variant: sale, sold-out, new, default |\n\n**Variant Styles:**\n\n| Variant | Background | Text Color |\n|---|---|---|\n| sale | Light red (dark: dark red tint) | Red |\n| sold-out | Light gray (dark: dark gray) | Medium gray |\n| new | Light blue (dark: dark blue tint) | Blue |\n| default | Light gray (dark: dark gray) | Dark gray |\n\n**Base appearance:** Inline pill shape, rounded-full, extra-small medium-weight text with horizontal padding.\n\n### storefront-quantity-selector\n\nNumber input with increment/decrement buttons.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| value | integer | 1 | Current quantity |\n| min | integer | 1 | Minimum allowed value |\n| max | integer or null | null | Maximum allowed value (null = unlimited) |\n| wireModel | string | Required | Livewire model binding string |\n| compact | boolean | false | Smaller variant for cart drawer |\n\n### storefront-address-form\n\nRenders a full address form with all fields.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| address | array or null | null | Pre-filled address data |\n| prefix | string | Empty | Livewire model prefix (e.g., \"shipping\" for shipping.firstName) |\n\n### storefront-order-summary\n\nRenders the checkout order summary sidebar.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |\n| showDiscountInput | boolean | true | Whether to show the discount code input |\n\n### storefront-breadcrumbs\n\nRenders a breadcrumb navigation trail.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| items | array | Required | Array of objects with label and url. Last item has no URL (current page) |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Breadcrumb\"\n- Ordered list with separator characters between items\n- Current page (last item) has a \"current page\" indicator and distinct styling\n- Includes structured data (BreadcrumbList schema) for SEO\n\n### storefront-pagination\n\nWraps Laravel's pagination with custom styling.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| paginator | LengthAwarePaginator | Required | Laravel paginator instance |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Pagination\"\n- Previous/Next buttons with arrow icons\n- Numbered page buttons with current page highlighted\n- Ellipsis for truncated ranges\n- Mobile: previous/next only with \"Page X of Y\" text\n\n---\n\n## 17. Livewire Component Summary\n\n| Component | Location | Purpose |\n|---|---|---|\n| FeaturedProducts | Home page | Lazy-loaded featured products grid |\n| NewsletterSignup | Home page, footer | Email subscription form |\n| CollectionPage | Collection page | Filtering, sorting, pagination |\n| ProductPage | Product page | Variant selection, add to cart |\n| CartDrawer | Layout (global) | Slide-in cart with line management |\n| CartPage | Cart page | Full-page cart |\n| CheckoutPage | Checkout | Multi-step checkout flow |\n| SearchModal | Layout (global) | Autocomplete search overlay |\n| SearchResults | Search page | Full search results with filters |\n| Login | Login page | Authentication form |\n| Register | Register page | Registration form |\n| AddressBook | Addresses page | CRUD for saved addresses |\n\n---\n\n## 18. Browser Event Communication\n\nLivewire components communicate via dispatched browser events:\n\n| Event Name | Dispatched By | Listened By | Payload |\n|---|---|---|---|\n| cart-updated | ProductPage, CartDrawer, CartPage | CartDrawer (opens + refreshes), Header (badge count) | cartId, itemCount |\n| open-cart-drawer | Header cart icon click | CartDrawer | None |\n| close-cart-drawer | CartDrawer close actions | CartDrawer | None |\n| open-search-modal | Header search icon click | SearchModal | None |\n| close-search-modal | SearchModal close actions | SearchModal | None |\n| variant-changed | ProductPage (variant selector) | ProductPage (internal) | variantId, price, stock |\n\n---\n\n## 19. SEO and Structured Data\n\n### Meta Tags\n\nEach page type sets appropriate meta tags:\n\n| Page | Title Pattern | Description Source |\n|---|---|---|\n| Home | Store name | Theme settings |\n| Collection | \"{Collection title} - {Store name}\" | Collection description (stripped of markup) |\n| Product | \"{Product title} - {Store name}\" | Product description (stripped, truncated to 160 chars) |\n| Content page | \"{Page title} - {Store name}\" | Page body (stripped, truncated) |\n| Search | \"Search results - {Store name}\" | Static |\n\n### Open Graph Tags\n\nProduct pages include Open Graph tags for social sharing:\n\n| Tag | Value |\n|---|---|\n| og:title | Product title |\n| og:description | Meta description |\n| og:image | Primary product image URL |\n| og:type | product |\n| og:url | Current page URL |\n| product:price:amount | Formatted price (dollars) |\n| product:price:currency | Store currency code |\n\n### JSON-LD Structured Data\n\n| Schema Type | Used On |\n|---|---|\n| Product | Product pages |\n| BreadcrumbList | Breadcrumb navigation on all pages |\n| Organization | Footer (store info) |\n\n---\n\n## 20. User Flow Diagrams\n\n### Browse to Purchase Flow\n\n```mermaid\nflowchart TD\n    A[Home Page] --> B{Browse}\n    B --> C[Collection Page]\n    B --> D[Search Modal]\n    D --> E[Search Results Page]\n    C --> F[Product Page]\n    E --> F\n    F --> G{Select Variant}\n    G --> H[Set Quantity]\n    H --> I[Add to Cart]\n    I --> J[Cart Drawer Opens]\n    J --> K{Continue?}\n    K -->|Continue Shopping| B\n    K -->|Apply Discount| L[Enter Discount Code]\n    L --> J\n    K -->|Checkout| M[Checkout Page]\n    M --> N[Step 1: Contact Info]\n    N --> O[Step 2: Shipping Address]\n    O --> P[Step 3: Shipping Method]\n    P --> Q[Step 4: Payment]\n    Q --> R[Stripe/PayPal Redirect]\n    R --> S[Order Confirmation]\n    S --> T{Next?}\n    T -->|Continue Shopping| A\n    T -->|View Order| U[Account Order Detail]\n```\n\n### Account Management Flow\n\n```mermaid\nflowchart TD\n    A[Login Page] --> B{Has Account?}\n    B -->|No| C[Register Page]\n    C --> D[Account Dashboard]\n    B -->|Yes| D\n    D --> E{Action}\n    E --> F[Order History]\n    F --> G[Order Detail]\n    G --> H[Track Shipment]\n    E --> I[Address Book]\n    I --> J{Action}\n    J --> K[Add Address]\n    J --> L[Edit Address]\n    J --> M[Delete Address]\n    J --> N[Set Default Address]\n    E --> O[Log Out]\n```\n\n### Cart Management Flow\n\n```mermaid\nflowchart TD\n    A[Add to Cart] --> B[Cart Drawer Opens]\n    B --> C{Action}\n    C --> D[Update Quantity]\n    D --> B\n    C --> E[Remove Item]\n    E --> F{Cart Empty?}\n    F -->|Yes| G[Empty Cart State]\n    G --> H[Continue Shopping]\n    F -->|No| B\n    C --> I[Apply Discount Code]\n    I --> J{Valid?}\n    J -->|Yes| K[Discount Applied]\n    K --> B\n    J -->|No| L[Error Message]\n    L --> B\n    C --> M[Proceed to Checkout]\n    M --> N[Checkout Page]\n    C --> O[View Full Cart Page]\n    O --> N\n```\n\n### Component State Diagram\n\n```mermaid\nstateDiagram-v2\n    [*] --> Empty: Cart initialized\n\n    state \"Cart Drawer\" as CD {\n        Empty --> HasItems: Add item\n        HasItems --> HasItems: Update quantity\n        HasItems --> HasItems: Apply discount\n        HasItems --> Empty: Remove last item\n        HasItems --> Checkout: Proceed to checkout\n    }\n\n    state \"Product Page\" as PP {\n        NoVariant --> VariantSelected: Select options\n        VariantSelected --> InStock: Stock > 0\n        VariantSelected --> OutOfStock: Stock = 0\n        InStock --> Adding: Click add to cart\n        Adding --> InStock: Complete\n        OutOfStock --> SoldOut: Deny policy\n        OutOfStock --> Backorder: Continue policy\n    }\n\n    state \"Checkout\" as CO {\n        Contact --> ShippingAddress: Complete step 1\n        ShippingAddress --> ShippingMethod: Complete step 2\n        ShippingMethod --> Payment: Complete step 3\n        Payment --> Processing: Submit payment\n        Processing --> Confirmation: Payment success\n        Processing --> Payment: Payment failure\n    }\n```\n\n---\n\n## 21. Flash / Toast Messages\n\n| Action | Message | Type |\n|--------|---------|------|\n| Item added to cart | \"Added to cart\" | success |\n| Item removed from cart | \"Item removed\" | success |\n| Cart is empty | \"Your cart is empty\" | info |\n| Discount applied | \"Discount applied\" | success |\n| Invalid discount code | \"Invalid discount code\" | error |\n| Out of stock | \"This product is currently out of stock\" | warning |\n| Registration success | \"Account created successfully\" | success |\n| Login success | (redirect, no message) | - |\n| Login failed | \"Invalid credentials\" | error |\n| Password reset sent | \"We have emailed your password reset link!\" | success |\n| Order placed | \"Thank you for your order!\" | success |\n","structuredPatch":[{"oldStart":1175,"oldLines":8,"newStart":1175,"newLines":8,"lines":[" |  [Items list with thumbnails]                                 |"," +--------------------------------------------------------------+"," |  Shipping Address          |  Payment Method                  |","-|  Jane Doe                  |  Visa ending in 4242             |","-|  123 Main St               |                                  |","+|  Jane Doe                  |  Credit Card ending in 4242      |","+|  123 Main St               |  (or: PayPal / Bank Transfer)    |"," |  New York, NY 10001        |                                  |"," +--------------------------------------------------------------+"," |  Subtotal        $84.97                                       |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"446bf03e-b70c-4168-8c74-ec7bf7a85b6b"}
{"parentUuid":"f6aecc7a-b52a-4799-b77f-191a0b219edc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BxXZNxrA9NZjdUhAdpS3KK","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the confirmation page details for payment method display and bank transfer instructions."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":321,"cache_read_input_tokens":121360,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":321},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WbRwbPifFUB6aYJm2Z","type":"assistant","uuid":"961a79f5-c568-4b7f-ac15-df7a6568cf5f","timestamp":"2026-02-11T15:22:54.943Z"}
{"parentUuid":"961a79f5-c568-4b7f-ac15-df7a6568cf5f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BxXZNxrA9NZjdUhAdpS3KK","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EKVz4Ux8K4jnfRhEZDqYWz","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","old_string":"### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Totals | Right-aligned summary |","new_string":"### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Payment method display | Shows \"Credit Card ending in {last4}\", \"PayPal\", or \"Bank Transfer\" depending on `order.payment_method` |\n| Totals | Right-aligned summary |\n\n#### Bank Transfer Instructions (shown only when `payment_method = bank_transfer`)\n\nA prominent callout (info variant) displayed between the order details and the totals:\n\n```\n+--------------------------------------------------------------+\n|  [info icon] Bank Transfer Instructions                       |\n|                                                               |\n|  Please transfer the total amount to the following account:   |\n|                                                               |\n|  Bank:     Mock Bank AG                                       |\n|  IBAN:     DE89 3704 0044 0532 0130 00                        |\n|  BIC:      COBADEFFXXX                                        |\n|  Amount:   29.98 EUR                                          |\n|  Reference: #1042                                             |\n|                                                               |\n|  Please complete your transfer within 7 days. Your order will |\n|  be processed once payment is confirmed by our team.          |\n+--------------------------------------------------------------+\n```\n\nThe reference field uses the order number so the admin can match incoming transfers."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":321,"cache_read_input_tokens":121360,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":321},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WbRwbPifFUB6aYJm2Z","type":"assistant","uuid":"22e7ec34-8565-4e20-b7fe-839b56e3e5b0","timestamp":"2026-02-11T15:23:03.690Z"}
{"parentUuid":"22e7ec34-8565-4e20-b7fe-839b56e3e5b0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01EKVz4Ux8K4jnfRhEZDqYWz","toolUseID":"toolu_01EKVz4Ux8K4jnfRhEZDqYWz","timestamp":"2026-02-11T15:23:03.695Z","uuid":"9cd558e3-8591-49bd-8f7a-93cf90b02d41"}
{"parentUuid":"22e7ec34-8565-4e20-b7fe-839b56e3e5b0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EKVz4Ux8K4jnfRhEZDqYWz","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md has been updated successfully."}]},"uuid":"40d9cf0c-a13c-4a17-81c4-382bec5e81cd","timestamp":"2026-02-11T15:23:03.694Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","oldString":"### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Totals | Right-aligned summary |","newString":"### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Payment method display | Shows \"Credit Card ending in {last4}\", \"PayPal\", or \"Bank Transfer\" depending on `order.payment_method` |\n| Totals | Right-aligned summary |\n\n#### Bank Transfer Instructions (shown only when `payment_method = bank_transfer`)\n\nA prominent callout (info variant) displayed between the order details and the totals:\n\n```\n+--------------------------------------------------------------+\n|  [info icon] Bank Transfer Instructions                       |\n|                                                               |\n|  Please transfer the total amount to the following account:   |\n|                                                               |\n|  Bank:     Mock Bank AG                                       |\n|  IBAN:     DE89 3704 0044 0532 0130 00                        |\n|  BIC:      COBADEFFXXX                                        |\n|  Amount:   29.98 EUR                                          |\n|  Reference: #1042                                             |\n|                                                               |\n|  Please complete your transfer within 7 days. Your order will |\n|  be processed once payment is confirmed by our team.          |\n+--------------------------------------------------------------+\n```\n\nThe reference field uses the order number so the admin can match incoming transfers.","originalFile":"# 04 - Storefront UI/UX Specification\n\n> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n| Templating | Blade templates with reusable storefront-prefixed components |\n| Accessibility | WCAG 2.2 AA compliance throughout |\n| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n\n---\n\n## Currency Formatting\n\nAll monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n\n| Setting | Value |\n|---------|-------|\n| Decimal separator | `.` (period) |\n| Thousands separator | `,` (comma) |\n| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n| Decimal places | Always 2 |\n| Free amounts | Display as `0.00 EUR` |\n| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n\n**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n\n**Examples:**\n- 2499 cents -> `24.99 EUR`\n- 0 cents -> `0.00 EUR`\n- 149900 cents -> `1,499.00 EUR`\n\n---\n\n## 1. Theme System Architecture\n\n### 1.1 View Organization\n\nThe storefront views are organized into the following categories:\n\n| Category | Purpose | Views |\n|---|---|---|\n| Layouts | Master page shell | app (master layout) |\n| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n| Checkout | Purchase flow | checkout index, order confirmation |\n| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n| Errors | Error states | 404, 503 |\n| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n\n### 1.2 Layout Hierarchy\n\nAll storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n\n### 1.3 Theme Settings\n\nTheme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n\n- Announcement bar visibility, text, link, and background color\n- Sticky header toggle\n- Home page section order and visibility\n- Color palette overrides (primary, secondary, accent)\n- Typography scale\n- Footer content and social links\n- Dark mode behavior (system preference, toggle, or forced)\n\n### 1.4 Section-Based Architecture\n\nEach page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n\n| Section | Description | Used On |\n|---|---|---|\n| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n| Featured Collections | Grid of collection cards | Home |\n| Featured Products | Grid of product cards (lazy-loaded) | Home |\n| Newsletter | Email signup form | Home, Footer |\n| Rich Text | Customizable content block | Home, Pages |\n\n### 1.5 Blade Components\n\nAll reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n\n---\n\n## 2. Base Layout\n\n### 2.1 Document Head\n\nThe document head includes:\n\n| Element | Description |\n|---|---|\n| Character encoding | UTF-8 |\n| Viewport | Width = device width, initial scale 1 |\n| Meta description | Page-specific, yielded from child templates |\n| Title | Page-specific title appended with the app name |\n| Stylesheets | Compiled CSS via Vite |\n| Scripts | Compiled JS via Vite |\n| Livewire styles | Injected by Livewire |\n| Language attribute | Set on the HTML element from application locale |\n| Scroll behavior | Smooth scrolling enabled globally |\n\n### 2.2 Skip Link\n\nA visually hidden \"Skip to main content\" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.\n\n**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.\n\n### 2.3 Announcement Bar\n\nOptional, controlled by theme settings. Renders a full-width bar above the header.\n\n| Attribute | Description |\n|---|---|\n| Position | Full-width, above the header |\n| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |\n| Text | Centered, small size, configurable content |\n| Link | Optional, underlined, opens in same tab |\n| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |\n| Height | Auto height with vertical padding |\n\n### 2.4 Header\n\nThe header serves as the primary navigation banner.\n\n**Desktop Layout (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |\n| Main Navigation | Center | Horizontal nav with links sourced from the \"main-menu\" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |\n| Search Icon | Right group | Button that opens the search modal |\n| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |\n| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |\n\n**Mobile Layout (below large breakpoint):**\n\n```\n+--------------------------------------------------------------+\n| [Hamburger]           [Logo]                            [Cart]|\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Hamburger menu | Left | Toggle button for mobile navigation drawer |\n| Logo | Center | Same as desktop but horizontally centered |\n| Cart icon | Right | Same as desktop |\n\n**Sticky Header (optional):**\n\nWhen enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.\n\n**Mobile Navigation Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Animation | Slides in from the left side |\n| Height | Full viewport height |\n| Backdrop | Semi-transparent dark overlay behind the drawer |\n| Close button | X icon at the top-right of the drawer |\n| Navigation items | Vertical stack with comfortable tap targets |\n| Submenus | Collapsible accordion pattern with chevron icons |\n| Account link | Positioned at the bottom of the navigation list |\n| Focus behavior | Focus is trapped within the drawer while open |\n| ARIA | Labeled as \"Mobile navigation\" |\n\n### 2.5 Main Content Area\n\nThe main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.\n\n### 2.6 Footer\n\nThe footer is a content-info landmark.\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n|                                                               |\n| [Social Icons]                                                |\n| [Copyright] [Payment Icons]                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n| Store info column | Store name, address (if configured), contact email |\n| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n| Background | Light gray (dark: very dark gray), top border separator |\n\n### 2.7 Cart Drawer (Livewire)\n\nThe cart drawer is always present in the layout as a global Livewire component. It listens for the \"cart-updated\" browser event to refresh and open. See Section 6 for full specification.\n\n### 2.8 Dark Mode\n\nDark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:\n\n| Mode | Implementation |\n|---|---|\n| System preference (default) | Follows OS-level dark mode preference automatically |\n| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |\n| Forced light/dark | Applied at server render time on the HTML element |\n\n**Color Token Patterns:**\n\n| Token | Light Mode | Dark Mode |\n|---|---|---|\n| Page background | White | Near-black |\n| Card/section background | Light gray | Very dark gray |\n| Heading text | Near-black | White |\n| Body text | Medium gray | Light gray |\n| Borders | Light gray | Medium-dark gray |\n| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |\n\n---\n\n## 3. Home Page (GET /)\n\nThe home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:\n\n1. Hero Banner\n2. Featured Collections\n3. Featured Products\n4. Newsletter Signup\n5. Rich Text\n\n### 3.1 Hero Banner Section\n\n**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.\n\n**Desktop (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n|                                                               |\n|  [Background Image / Gradient]                                |\n|                                                               |\n|       [Heading - very large, bold]                            |\n|       [Subheading - medium, regular weight]                   |\n|       [CTA Button - primary, large]                           |\n|                                                               |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |\n| Content | Centered vertically and horizontally, white text |\n| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |\n| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |\n| CTA | Styled as primary button, large. Links to a configured collection or product |\n| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |\n| Padding | Generous vertical and horizontal padding |\n\n**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.\n\n**Accessibility:**\n\n- If background is an image, it is decorative (the content provides meaning)\n- CTA button has descriptive text (e.g., \"Shop the summer collection\")\n\n### 3.2 Featured Collections Section\n\n**Layout:** Section with heading and grid of collection cards.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, large, bold, centered, with generous bottom margin |\n| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |\n| Count | 2 to 4 collections, configurable in theme settings |\n\n**Collection Card:**\n\n```\n+---------------------------+\n|  [Collection Image]       |\n|  3:4 aspect ratio         |\n|  cover-fit, rounded       |\n|                           |\n|  [Title overlay at bottom]|\n|  [Shop now ->]            |\n+---------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |\n| Title | White text overlaid at bottom of image, semi-bold, medium-large size |\n| \"Shop now\" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |\n| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |\n\n**Accessibility:**\n\n- Each card link has the collection title as accessible name\n- Images have alt text matching the collection title\n\n### 3.3 Featured Products Section\n\n**Livewire Component:** FeaturedProducts\n\nThis section uses Livewire for lazy loading to improve initial page load.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, same styling as featured collections heading |\n| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |\n| Products | 4 to 8, configurable. Sourced from a configured collection or \"best sellers\" logic |\n| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |\n\nEach product uses the product-card component (see Section 16).\n\n### 3.4 Newsletter Signup Section\n\n**Livewire Component:** NewsletterSignup\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n|  [Heading: \"Stay in the loop\"]                                |\n|  [Subtext: \"Subscribe for exclusive offers and updates.\"]     |\n|  [Email input] [Subscribe button]                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Light gray (dark mode: darker gray), generous vertical padding |\n| Content | Centered, constrained to a comfortable width |\n| Heading | H2, medium-large, bold |\n| Subtext | Muted color, small top margin |\n| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |\n| Input | Email type, placeholder \"Enter your email\", required |\n| Button | Primary style, \"Subscribe\" text |\n| Success state | Replaces form with \"Thanks for subscribing!\" message |\n| Error state | Inline error below input in red, small text |\n| Loading state | Button text changes to \"Subscribing...\" during submission |\n\n**Accessibility:**\n\n- Label associated with email input (visually hidden if using placeholder)\n- Success/error messages in a polite live region for screen readers\n\n### 3.5 Rich Text Section\n\nSimple section rendering customizable content from theme settings.\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained to comfortable reading width, centered, vertical padding |\n| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |\n| Dark mode | Inverted prose styling |\n\n---\n\n## 4. Collection Page (GET /collections/{handle})\n\n**Livewire Component:** CollectionPage\n\n### 4.1 Collection Header\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > Collections > {Collection Title} |\n| Title | H1, large, bold |\n| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |\n| Spacing | Generous vertical padding |\n\n### 4.2 Toolbar\n\nHorizontal bar between the header and the product grid.\n\n**Desktop:**\n\n```\n+--------------------------------------------------------------+\n| [Filter toggle button]          [X products] [Sort dropdown]  |\n+--------------------------------------------------------------+\n```\n\n**Mobile:**\n\n```\n+--------------------------------------------------------------+\n| [Filter button]                           [Sort dropdown]     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Product count | Small muted text, e.g., \"24 products\" |\n| Sort dropdown | Select element with live Livewire binding |\n\n**Sort Options:**\n\n| Option | Description |\n|---|---|\n| Featured | Default sort order |\n| Price: Low to High | Ascending by price |\n| Price: High to Low | Descending by price |\n| Newest | Descending by creation date |\n| Best Selling | Descending by sales count |\n\n### 4.3 Filter Sidebar\n\n**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.\n**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by \"Filter\" button with filter icon.\n\n**Filter Groups:**\n\nEach filter group is a collapsible section with a heading and toggle chevron.\n\n| Filter Group | Type | Binding | Description |\n|---|---|---|---|\n| Availability | Single checkbox | Live | \"In stock\" toggle |\n| Price Range | Two number inputs | Live with 500ms debounce | \"Min\" and \"Max\" inputs with currency symbol prefix, displayed side by side |\n| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |\n| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |\n\n**Clear All:**\n\n- Link at the top of the filter sidebar: \"Clear all filters\"\n- Only visible when at least one filter is active\n- Small, blue, interactive text\n\n**Active Filter Pills:**\n\n- Displayed above the product grid (below toolbar) when filters are active\n- Each pill: rounded tag with filter value text and a remove (X) button\n- \"Clear all\" link at the end of the pills row\n\n**Mobile Filter Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Position | Full-height overlay from left side |\n| Header | \"Filters\" title + close button (X) |\n| Content | Same filter groups as desktop, stacked vertically |\n| Footer | Sticky \"Apply\" button at bottom that closes the drawer |\n| Clear | \"Clear all\" link in header area |\n| Focus | Trapped within drawer while open |\n\n### 4.4 Product Grid\n\n| Attribute | Description |\n|---|---|\n| Layout | Fills remaining width next to the sidebar |\n| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |\n| Loading state | Subtle opacity reduction on the grid while results update |\n\n### 4.5 Product Card (Component)\n\nThe product card is a reusable component used on collection pages, search results, and featured products sections.\n\n```\n+---------------------------+\n|  [Product Image]          |\n|  1:1 aspect ratio         |\n|  cover-fit, rounded       |\n|  (hover: show 2nd image)  |\n|                           |\n|  [Sale badge]  [Sold out] |\n+---------------------------+\n|  [Product Title]          |\n|  [Price]  [Compare price] |\n|  [Quick Add / Options]    |\n+---------------------------+\n```\n\n**Image Behavior:**\n\n| Attribute | Description |\n|---|---|\n| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |\n| Primary image | Cover-fit, fills the container |\n| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |\n| Fallback | If no image, show a centered shopping bag placeholder icon |\n| Loading | Lazy-loaded |\n\n**Badges:**\n\n| Badge | Condition | Position |\n|---|---|---|\n| \"Sale\" | compare_at_amount > price_amount | Top-left of image, stacked vertically |\n| \"Sold out\" | All variants out of stock with deny policy | Top-left of image, stacked vertically |\n\n**Text Content:**\n\n| Element | Description |\n|---|---|\n| Title | H3 (or configurable heading level), small semi-bold text. Truncated to 2 lines |\n| Price | Current price in small semi-bold text. Compare-at price shown with strikethrough if applicable |\n| Quick Add | Single variant: \"Add to cart\" button. Multiple variants: \"Choose options\" link text. Hidden by default on desktop (shown on hover), always visible on mobile |\n\n**Accessibility:**\n\n- Card link wraps the entire card; title serves as the accessible name\n- Badges have screen reader labels (e.g., \"On sale\")\n- Price changes announced via parent live region when filtering changes results\n\n### 4.6 Pagination\n\n| Attribute | Description |\n|---|---|\n| Style | Numbered pagination with previous/next arrow buttons |\n| Current page | Highlighted with primary color background and white text |\n| Other pages | Muted text, hover background |\n| Disabled | Reduced opacity and not-allowed cursor on previous (at first page) or next (at last page) |\n| Truncation | Ellipsis for large page counts |\n| Mobile | Simplified: previous/next only with current page indicator |\n\n### 4.7 Empty State\n\nWhen no products match the current filters:\n\n```\n+--------------------------------------------------------------+\n|  [Search icon - large, muted]                                 |\n|  \"No products found\"                                          |\n|  \"Try adjusting your filters or browse our full collection.\"  |\n|  [Clear filters button]                                       |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Icon | Large, muted search icon |\n| Heading | Medium, semi-bold |\n| Subtext | Muted, descriptive guidance |\n| Button | Secondary style, \"Clear filters\" |\n| Position | Centered in the grid area with generous vertical padding |\n\n---\n\n## 5. Product Page (GET /products/{handle})\n\n**Livewire Component:** ProductPage\n\n### 5.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column, 50/50 split. Left column (images) is sticky while the right column (product info) scrolls.\n\n**Mobile:** Single column, stacked. Images first, then product info.\n\n```\nDesktop:\n+-------------------------------+-------------------------------+\n|  [Image Gallery]              |  [Product Info]               |\n|  (sticky while scrolling)     |  (scrollable)                 |\n+-------------------------------+-------------------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Image Gallery - horizontal scroll]                          |\n+--------------------------------------------------------------+\n|  [Product Info - full width]                                  |\n+--------------------------------------------------------------+\n```\n\nContainer constrained to standard max-width with horizontal padding and vertical spacing.\n\n### 5.2 Image Gallery (Left Column)\n\n**Desktop:**\n\n```\n+-------------------------------+\n|                               |\n|  [Main Image - large]         |\n|  1:1 aspect ratio, rounded    |\n|                               |\n+-------------------------------+\n| [Thumb] [Thumb] [Thumb] ...  |\n+-------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Main image | Square aspect ratio, large rounded corners, cover-fit. Gray placeholder background |\n| Zoom on hover | Optional (theme setting): image scales to 150% and follows cursor position |\n| Thumbnail strip | Horizontal row with overflow scroll, small gap between thumbnails |\n| Each thumbnail | 64x64px, rounded, cover-fit, pointer cursor. Active thumbnail has a colored border |\n| Variant images | When a variant is selected that has specific images, those images are shown |\n\n**Mobile:**\n\n| Attribute | Description |\n|---|---|\n| Gallery | Horizontal scrollable row with snap-scroll behavior |\n| Each image | Full-width, square aspect ratio, rounded, cover-fit |\n| Scroll indicators | Row of dots below the gallery. Active dot is dark (light in dark mode), inactive dots are gray |\n\n**Accessibility:**\n\n| Requirement | Description |\n|---|---|\n| Alt text | Main image has descriptive alt text from product media data |\n| Thumbnails | Rendered as buttons with labels \"View image X of Y\" and current state indicator |\n| Region | Gallery wrapped in a labeled region (\"Product images\") |\n\n### 5.3 Product Info (Right Column)\n\n#### Breadcrumbs\n\nHome > {Primary Collection} > {Product Title}. Small, muted text with separator characters.\n\n#### Product Title\n\nH1, large, bold.\n\n#### Price Display\n\n| Element | Description |\n|---|---|\n| Current price | Large, bold |\n| Compare-at price | Medium, muted, strikethrough (shown when set and higher than current price) |\n| Sale badge | Inline badge next to the price |\n| Currency | Formatted based on the store's default currency |\n| Live updates | Price container is a live region, so price changes when variant changes are announced to screen readers |\n\n#### Variant Selector\n\nFor each product option (e.g., Size, Color), render a selector group.\n\n**Radio button style (for options with 6 or fewer values):**\n\n```\nSize\n[S] [M] [L] [XL] [XXL]\n```\n\n| State | Visual Description |\n|---|---|\n| Available | Bordered pill shape, pointer cursor, border darkens on hover |\n| Selected | Primary color border with subtle background tint and focus ring |\n| Unavailable | Muted border, muted text, strikethrough, reduced opacity, not-allowed cursor |\n\n**Select dropdown style:** Used when the option has more than 6 values.\n\n**Color swatches (when option name is \"Color\"):**\n\n| Attribute | Description |\n|---|---|\n| Shape | Circular, 32x32px |\n| Color | Determined by value name mapped to a color lookup or inline style |\n| Selected state | Ring with offset around the swatch |\n| Tooltip | Shows color name on hover |\n\n**Livewire behavior:**\n\n- Selected options are a key-value map (e.g., Size = M, Color = Red)\n- Selected variant is computed by matching all selected options to the variant option values\n- On change: updates price, stock messaging, available images, and add-to-cart availability\n\n#### Stock Messaging\n\nDisplayed below the variant selector.\n\n| Condition | Message | Visual Style |\n|---|---|---|\n| In stock, quantity > 10 | \"In stock\" | Green text with check icon |\n| In stock, quantity <= 10 | \"Only X left in stock\" | Amber text with warning icon |\n| Out of stock, deny policy | \"Out of stock\" | Red text with X icon |\n| Out of stock, continue policy | \"Available on backorder\" | Blue text with info icon |\n\nSmall text, icon + text inline. Wrapped in a live region for screen reader announcements on variant change.\n\n#### Quantity Selector\n\nUses the quantity-selector component.\n\n```\n[-] [3] [+]\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Inline flex, bordered, rounded |\n| Decrease button | \"-\" icon, 40x40px. Disabled at minimum (1) |\n| Input | Centered number input, 56px wide, 40px tall. Min = 1, Max = available stock (deny policy) or unlimited (continue policy) |\n| Increase button | \"+\" icon, same size. Disabled at maximum |\n| Disabled state | Reduced opacity, not-allowed cursor |\n| Labels | Input labeled \"Quantity\", buttons labeled \"Decrease quantity\" / \"Increase quantity\" |\n\n#### Add to Cart Button\n\n| Attribute | Description |\n|---|---|\n| Width | Full width |\n| Style | Primary button: blue background, white text, semi-bold, generous padding, rounded |\n| Text | \"Add to cart\" |\n| Loading state | Text changes to \"Adding...\" with spinner icon. Button disabled during loading |\n| Sold out state | Gray background, muted text, not-allowed cursor. Text: \"Sold out\" |\n| Spacing | Generous top margin |\n\n**Livewire action (addToCart):**\n\n1. Validates variant is selected and in stock\n2. Adds to cart (creates cart if none exists in session)\n3. Dispatches \"cart-updated\" browser event\n4. Cart drawer opens automatically\n\n#### Product Description\n\n- Separated from above content by a horizontal rule with generous vertical spacing\n- Rendered HTML styled with prose/typography styling\n\n#### Tags\n\n- Displayed below the description (if tags exist)\n- Horizontal wrapping row with gap\n- Each tag: rounded pill, gray background, small text\n\n### 5.4 Accessibility Summary\n\n| Requirement | Implementation |\n|---|---|\n| Variant radio buttons | Properly labeled with labels and grouped with fieldset/legend |\n| Live regions | Price display, stock messaging, add-to-cart feedback |\n| Image alt text | All images have meaningful alt text |\n| Keyboard | Tab through thumbnails, variant options, quantity controls, and add-to-cart |\n| Focus indicators | Visible ring styles on all interactive elements |\n\n---\n\n## 6. Cart Drawer\n\n**Livewire Component:** CartDrawer\n\n### 6.1 Trigger and Animation\n\n| Attribute | Description |\n|---|---|\n| Trigger | \"cart-updated\" browser event (from add-to-cart actions), or clicking the cart icon in the header |\n| Animation | Slides in from the right side of the viewport. Transforms from fully off-screen to visible |\n| Backdrop | Fixed full-screen semi-transparent dark overlay. Click to close |\n| Panel | Fixed, right-aligned, full height, full width on small screens or 384px on larger screens. White background (dark mode: near-black), shadow |\n| Body | Scroll locked when drawer is open |\n\n### 6.2 Drawer Header\n\n```\n+--------------------------------------+\n| Your Cart (3)                    [X] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Heading | H2, semi-bold, medium-large text |\n| Item count | In parentheses after heading |\n| Close button | X icon, labeled \"Close cart\" |\n| Layout | Flex row, space-between, with horizontal padding and bottom border |\n\n### 6.3 Cart Line Items\n\nScrollable list area occupying the space between header and totals.\n\nEach line item:\n\n```\n+--------------------------------------+\n| [Thumb]  Title                       |\n|          Variant: Size M / Blue      |\n|          [-] [2] [+]       $49.98    |\n|                             [Remove] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Thumbnail | 64x64px, rounded, cover-fit, non-shrinking |\n| Title | Small semi-bold text, truncated to 1 line |\n| Variant info | Extra-small muted text showing selected options |\n| Quantity stepper | Compact version of quantity-selector (smaller buttons, 32x32px) |\n| Line total | Small semi-bold text |\n| Remove button | Trash icon, muted color, turns red on hover. Labeled \"Remove {item title} from cart\" |\n| Separation | Bottom border between items, vertical padding per item |\n| Loading | Opacity reduction on the line item during quantity update |\n\n### 6.4 Discount Code Section\n\nBelow the line items list, above totals.\n\n**No discount applied:**\n\n```\n+--------------------------------------+\n| [Discount code input] [Apply]        |\n+--------------------------------------+\n```\n\n**Discount applied:**\n\n```\n+--------------------------------------+\n| SUMMER20 (-15%)              [Remove]|\n+--------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Input | Small text, placeholder \"Discount code\" |\n| Apply button | Secondary style, small |\n| Success state | Green text showing the code and discount type, with a remove button |\n| Error state | Small red text below, e.g., \"Invalid discount code\" or \"This code has expired\" |\n| Loading state | Button text changes to \"Applying...\" |\n\n### 6.5 Totals Section\n\n```\n+--------------------------------------+\n| Subtotal                     $149.97 |\n| Discount (SUMMER20)          -$22.50 |\n| ------------------------------------ |\n| Estimated total              $127.47 |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Position | Sticky at the bottom of the drawer, with top border |\n| Each line | Flex row, justified space-between, small text |\n| Discount line | Only shown when discount applied, green text |\n| Total | Slightly larger text, semi-bold |\n| Note | \"Shipping and taxes calculated at checkout\" in extra-small muted text below totals |\n\n### 6.6 Action Buttons\n\n| Button | Description |\n|---|---|\n| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |\n| Continue shopping | Centered text link below checkout button. Small muted text, hover to darker. Closes the drawer |\n\n### 6.7 Empty Cart State\n\n```\n+--------------------------------------+\n|                                      |\n|  [Shopping bag icon - large]         |\n|  \"Your cart is empty\"                |\n|  [Continue shopping button]          |\n|                                      |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Centered vertically and horizontally in the drawer content area |\n| Icon | Large, very muted shopping bag icon |\n| Text | Muted, with top margin |\n| Button | Secondary style, closes the drawer |\n\n### 6.8 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Role | Dialog with modal behavior, labeled \"Shopping cart\" |\n| Focus trap | Tab/Shift+Tab cycles through drawer elements only |\n| On open | Focus moves to the close button or first interactive element |\n| On close | Focus returns to the element that triggered the drawer (cart icon) |\n| Escape key | Closes the drawer |\n| Feedback | All quantity changes and removals announced via polite live region |\n\n---\n\n## 7. Full Cart Page (GET /cart)\n\n**Livewire Component:** CartPage\n\nSame functionality as the cart drawer but in a full-page layout.\n\n### 7.1 Desktop Layout\n\n```\n+--------------------------------------------------------------+\n|  Your Cart                                                    |\n+--------------------------------------------------------------+\n|  Image | Product      | Price   | Quantity | Total   |       |\n|  ------|------------- |---------|----------|---------|-------|\n|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n|        | Size: M      |         |          |         |       |\n|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n|        | Color: Blue  |         |          |         |       |\n+--------------------------------------------------------------+\n|                                            Subtotal: $84.97   |\n|                              [Discount code] [Apply]          |\n|                                            Discount: -$12.75  |\n|                                            Total:    $72.22   |\n|                                                               |\n|                                            [Checkout button]  |\n|                                            [Continue shopping]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n| Title | H1, large bold, generous bottom margin |\n| Desktop layout | Proper table with header row and body |\n| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n\n### 7.2 Totals and Actions\n\n- Right-aligned on desktop, full-width on mobile\n- Same discount code input/apply functionality as drawer\n- Same totals breakdown\n- \"Checkout\" button: primary, aligned right\n- \"Continue shopping\" link: below checkout button\n\n### 7.3 Empty State\n\nSame as drawer empty state but centered in the page content area with more vertical padding.\n\n---\n\n## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment Method & Pay\n\nAll payment is handled onsite via the Mock PSP. No external redirects, no iframes, no third-party scripts.\n\n**Payment method selection (radio buttons):**\n\n```\n+--------------------------------------------------------------+\n| Select a payment method                                       |\n|                                                               |\n| (o) Credit Card                                               |\n| ( ) PayPal                                                    |\n| ( ) Bank Transfer                                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Layout | Radio button list, each option in a bordered card (same styling as shipping method radios) |\n| Default | First option (Credit Card) is pre-selected |\n| Live binding | On selection, the payment form below updates to show the relevant fields |\n\n**Credit Card form (shown when Credit Card is selected):**\n\n```\n+--------------------------------------------------------------+\n| Card number     [4242 4242 4242 4242        ]                |\n| Cardholder name [John Doe                   ]                |\n| Expiry          [12/28     ]  CVC [123    ]                  |\n+--------------------------------------------------------------+\n| [Pay now - 29.98 EUR]                                        |\n+--------------------------------------------------------------+\n```\n\n| Field | Type | Required | Validation |\n|---|---|---|---|\n| Card number | Text (digits, auto-formatted with spaces every 4 digits) | Yes | 16 digits |\n| Cardholder name | Text | Yes | Max 255 |\n| Expiry | Text (MM/YY format) | Yes | Valid future date |\n| CVC | Text (3-4 digits) | Yes | 3-4 digits |\n\n**PayPal form (shown when PayPal is selected):**\n\n```\n+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Pay with PayPal - {total}\".\n\n**Bank Transfer form (shown when Bank Transfer is selected):**\n\n```\n+--------------------------------------------------------------+\n| After placing your order, you will receive bank transfer      |\n| instructions. Your order will be held for 7 days while we     |\n| await your payment.                                           |\n|                                                               |\n| [Place order - 29.98 EUR]                                     |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Place order - {total}\" (not \"Pay now\", since payment is deferred).\n\n| Element | Description |\n|---|---|\n| Pay/Place button | Primary, full-width, large text. Loading state: \"Processing...\" with spinner. Calls `POST /checkouts/{id}/pay` with the selected method and card details (if credit card). |\n| Error handling | On payment decline (magic card number), display an inline error callout: \"Payment declined: {reason}\". The customer can correct details and retry. |\n| Success (card/paypal) | On successful instant capture, redirect to confirmation page. |\n| Success (bank_transfer) | On successful order placement, redirect to confirmation page (which shows bank transfer instructions). |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.\n\n```\n+--------------------------------------------------------------+\n|  [Checkmark icon - green circle]                              |\n|  \"Thank you for your order!\"                                  |\n|  Order #1042                                                  |\n|  \"We've sent a confirmation to you@email.com\"                 |\n+--------------------------------------------------------------+\n|  Order Summary                                                |\n|  [Items list with thumbnails]                                 |\n+--------------------------------------------------------------+\n|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Credit Card ending in 4242      |\n|  123 Main St               |  (or: PayPal / Bank Transfer)    |\n|  New York, NY 10001        |                                  |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  [Continue shopping button]  [View order - if logged in]      |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained to a comfortable width, centered, generous vertical padding |\n| Success icon | Large green checkmark in a circle, centered |\n| Heading | H1, large bold, centered, generous top margin |\n| Order number | Medium muted text, centered |\n| Email notice | Small muted text, centered |\n\n### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Totals | Right-aligned summary |\n\n### 9.3 Actions\n\n| Button | Description |\n|---|---|\n| Continue shopping | Primary, links to home page |\n| View order | Link to account order detail page (logged-in customers only) |\n| Layout | Centered row with gap |\n\n---\n\n## 10. Customer Account Pages\n\nAll account pages require authentication. Unauthenticated access redirects to the login page.\n\n### 10.1 Login (GET /account/login)\n\n**Livewire Component:** Login\n\n```\n+--------------------------------------------------------------+\n|  Log in to your account                                       |\n|                                                               |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Forgot password? link]                                      |\n|  [Log in button]                                              |\n|                                                               |\n|  Don't have an account? [Create one]                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, autocomplete for current password |\n| Forgot password | Small blue link, right-aligned |\n| Submit button | Primary, full-width, \"Log in\" |\n| Error messages | Inline below fields in small red text |\n| General error | Invalid credentials shown in a callout/alert above the form |\n| Register link | \"Create one\" link to registration page |\n\n### 10.2 Register (GET /account/register)\n\n**Livewire Component:** Register\n\n```\n+--------------------------------------------------------------+\n|  Create an account                                            |\n|                                                               |\n|  [Name input]                                                 |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Confirm password input]                                     |\n|  [ ] Subscribe to marketing emails                            |\n|  [Create account button]                                      |\n|                                                               |\n|  Already have an account? [Log in]                            |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Name | Text type, required, autocomplete for name |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, minimum 8 characters, autocomplete for new password |\n| Confirm password | Password type, required, autocomplete for new password |\n| Marketing checkbox | Checkbox with label \"Subscribe to marketing emails\" |\n| Submit button | Primary, full-width, \"Create account\" |\n| On success | Redirect to account dashboard |\n\n### 10.3 Account Dashboard (GET /account)\n\n```\n+--------------------------------------------------------------+\n|  Welcome back, Jane!                                          |\n+--------------------------------------------------------------+\n|  [Orders card]     [Addresses card]     [Log out card]        |\n+--------------------------------------------------------------+\n|  Recent Orders                                                |\n|  # | Date       | Status  | Total   | Action                 |\n|  --|------------|---------|---------|--------                 |\n|  1042 | Jan 15  | Paid    | $98.70  | View                   |\n|  1041 | Jan 10  | Shipped | $45.00  | View                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained max-width, centered, with padding |\n| Welcome heading | H1, medium-large bold |\n| Quick links | Responsive grid: 1 column on mobile, 3 on tablet+. Each card has border, rounded corners, padding, hover shadow |\n| Orders card | Order icon, \"Order history\", \"View all your orders\" |\n| Addresses card | Location icon, \"Addresses\", \"Manage your addresses\" |\n| Log out card | Logout icon, \"Log out\", submits POST logout form |\n| Recent orders | Table showing last 5 orders. Status shown as badge component |\n\n### 10.4 Order History (GET /account/orders)\n\n| Attribute | Description |\n|---|---|\n| Heading | H1, \"Order History\" |\n| Table columns | Order (link), Date, Status (badge), Total, Action (\"View\" link) |\n| Status badge colors | Pending: yellow. Paid: green. Fulfilled: blue. Cancelled: gray. Refunded: red |\n| Mobile | Card-based layout instead of table. Each card shows order number, date, status, and total |\n| Pagination | Standard pagination component |\n\n### 10.5 Order Detail (GET /account/orders/{orderNumber})\n\n```\n+--------------------------------------------------------------+\n|  Order #1042                      Status: [Paid] [Fulfilled] |\n|  Placed on January 15, 2026                                  |\n+--------------------------------------------------------------+\n|  Items                                                        |\n|  [Thumb] Product Title x2              $49.98                 |\n|          Size: M                                              |\n+--------------------------------------------------------------+\n|  Shipping Address    |  Billing Address   | Payment           |\n|  Jane Doe            |  Same as shipping  | Visa ****4242     |\n|  123 Main St         |                    |                   |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Discount        -$0.00                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  Fulfillment                                                  |\n|  Shipped via UPS - 1Z999AA10123456784                         |\n|  [Track shipment ->]                                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Account > Orders > #1042 |\n| Header | Order number and status badges |\n| Date | Small muted text |\n| Items | Table with thumbnail, title + variant info, quantity, line total |\n| Info grid | Three columns on desktop (shipping, billing, payment), stacked on mobile |\n| Totals | Summary breakdown |\n| Fulfillment | Only shown if fulfillments exist. Shows tracking company, number, and link to tracking URL (opens in new tab safely) |\n\n### 10.6 Address Book (GET /account/addresses)\n\n**Livewire Component:** AddressBook\n\n```\n+--------------------------------------------------------------+\n|  Your Addresses                          [+ Add new address]  |\n+--------------------------------------------------------------+\n|  [Default badge]              |                               |\n|  Jane Doe                     |  Jane Doe                     |\n|  123 Main St                  |  456 Oak Ave                  |\n|  New York, NY 10001           |  Chicago, IL 60601            |\n|  United States                |  United States                |\n|  [Edit] [Delete]              |  [Edit] [Delete] [Set default]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Grid | 1 column on mobile, 2 on tablet, 3 on desktop |\n| Each card | Bordered, rounded, with padding. Default address has a primary-colored border and \"Default\" badge |\n| Content | Formatted address lines |\n| Actions | Edit and Delete text buttons, with \"Set as default\" on non-default addresses |\n| Add new | Opens modal with address-form component |\n| Edit | Opens same modal pre-filled |\n| Delete | Confirmation dialog before deleting |\n\n---\n\n## 11. Search\n\n### 11.1 Search Modal\n\n**Livewire Component:** SearchModal\n\nTriggered by clicking the search icon in the header.\n\n```\n+--------------------------------------------------------------+\n|  [Search input with icon]                           [X close] |\n+--------------------------------------------------------------+\n|  Products                                                     |\n|  [Thumb] Product Title            $24.99                      |\n|  [Thumb] Product Title            $34.99                      |\n|                                                               |\n|  Collections                                                  |\n|  Summer Collection                                            |\n|  Winter Collection                                            |\n|                                                               |\n|  [View all X results ->]                                      |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Overlay | Full-screen semi-transparent dark backdrop |\n| Modal | Centered, moderate max-width, rounded corners, shadow |\n| Search input | Large text, search icon prefix, autofocus on open. Live binding with 300ms debounce |\n| Results | Appear below input as user types. Grouped by type: \"Products\" and \"Collections\" headings |\n| Product results | Thumbnail, title, price. Clickable to product page |\n| Collection results | Title only. Clickable to collection page |\n| Max results | 5 per category in the modal |\n| View all | Link at the bottom navigating to the full search results page |\n| Loading | Skeleton lines while fetching |\n| No results | \"No results for '{query}'\" message |\n| Keyboard | Escape closes, arrow keys navigate results, Enter selects highlighted result |\n| Focus | Trapped within the modal |\n| ARIA | Dialog role, modal, labeled \"Search\". Results list as listbox with option items |\n\n### 11.2 Search Results Page (GET /search?q=)\n\n**Livewire Component:** SearchResults\n\nSame layout as the collection page (Section 4) with the following differences:\n\n| Difference | Description |\n|---|---|\n| Heading | H1, \"X results for '{query}'\" or \"Search results for '{query}'\" |\n| Breadcrumbs | Home > Search results |\n| Filters | Same filter sidebar (availability, price, type, vendor) |\n| Sort | Same sort dropdown |\n| Product grid | Same product card component |\n| Pagination | Same pagination |\n| Empty state | \"No results found for '{query}'. Try a different search term.\" with a search input to try again |\n\n---\n\n## 12. Content Pages (GET /pages/{handle})\n\n### 12.1 Layout\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > {Page Title} |\n| Title | H1, large bold |\n| Body | Rendered HTML from body_html, styled with prose/typography styling, constrained to comfortable reading width (configurable to full width via theme settings) |\n| Container | Standard max-width, centered, with padding |\n\n---\n\n## 13. Error Pages\n\n### 13.1 404 Page\n\n```\n+--------------------------------------------------------------+\n|  [404 - large, bold, very muted]                              |\n|  \"Page not found\"                                             |\n|  \"The page you're looking for doesn't exist or has been       |\n|   moved.\"                                                     |\n|                                                               |\n|  [Search input]                                               |\n|  [Go to home page button]                                     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered vertically and horizontally |\n| 404 text | Very large, bold, extremely muted (barely visible as a background element) |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Search | Simple input + button, submits to search page |\n| Home link | Secondary button |\n\n### 13.2 503 / Maintenance Page\n\n```\n+--------------------------------------------------------------+\n|  [Store logo]                                                 |\n|  \"We'll be back soon\"                                         |\n|  \"We're currently performing maintenance. Please check back   |\n|   shortly.\"                                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered column |\n| Logo | Standard height, centered |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Style | Consistent with the store theme (colors, fonts) |\n\n---\n\n## 14. Responsive Breakpoints\n\nDefault breakpoints used across the storefront:\n\n| Breakpoint | Min-width | Typical Devices |\n|---|---|---|\n| Base (none) | 0px | Mobile phones (portrait) |\n| Small | 640px | Mobile phones (landscape), small tablets |\n| Medium | 768px | Tablets (portrait) |\n| Large | 1024px | Tablets (landscape), small desktops |\n| Extra-large | 1280px | Desktops |\n\n### Key Responsive Behaviors\n\n| Component | Mobile (base) | Tablet (medium) | Desktop (large+) |\n|---|---|---|---|\n| Navigation | Hamburger + drawer | Hamburger + drawer | Horizontal nav bar |\n| Product grid | 2 columns | 3 columns | 3-4 columns |\n| Product page | Stacked (images, then info) | Stacked | Side-by-side 50/50 |\n| Cart drawer | Full-screen overlay | 384px sidebar | 384px sidebar |\n| Checkout | Single column, summary collapsed | Single column | Two columns (form + summary) |\n| Footer | Stacked columns | 2-column grid | 3-4 column grid |\n| Collection filters | Drawer overlay | Drawer overlay | Persistent sidebar |\n| Account tables | Card-based layout | Card-based layout | Table layout |\n\n### Touch Considerations\n\n| Consideration | Requirement |\n|---|---|\n| Tap targets | Minimum 44x44px for all interactive elements on mobile |\n| Hover effects | Only applied on devices with hover capability |\n| Swipe gestures | Horizontal swipe on product images (mobile gallery), cart drawer close |\n\n---\n\n## 15. Accessibility Checklist\n\n### Global (All Pages)\n\n| Requirement | Description |\n|---|---|\n| Skip link | \"Skip to main content\" link, first element in body |\n| Landmark roles | Banner for header, main for content, contentinfo for footer, labeled nav elements |\n| Heading hierarchy | Single H1 per page, logical H2-H6 nesting |\n| Focus indicators | Visible ring on all interactive elements with offset |\n| Color contrast | Minimum 4.5:1 for normal text, 3:1 for large text (WCAG AA) |\n| Keyboard navigation | All interactive elements reachable via Tab, operable via Enter/Space |\n| Screen reader text | Visually hidden text for icon-only buttons (close, cart, search) |\n| Language | Lang attribute on HTML element |\n| Images | All images have alt attributes; decorative images marked appropriately |\n\n### Page-Specific Accessibility\n\n| Page | Requirements |\n|---|---|\n| Collection Page | Live region for product count/grid on filter change. Filter sidebar labeled as navigation. Sort dropdown has associated label |\n| Product Page | Variant selector uses fieldset/legend grouping. Price and stock in live regions. Image gallery thumbnails as buttons with current state |\n| Cart Drawer | Dialog role with modal behavior, labeled \"Shopping cart\". Focus trap. Quantity changes in live region |\n| Checkout | Form validation with describedby linking to errors, invalid state on fields. Focus management on step transitions. Shipping radios in fieldset/legend. Totals in live region |\n| Search Modal | Dialog role with modal behavior. Results as listbox with option items. Active descendant tracking for keyboard navigation |\n| Account Pages | All form inputs labeled. Tables use proper header markup with column scope. Status badges readable by text (not color-only) |\n\n### Keyboard Interactions\n\n| Component | Key | Action |\n|---|---|---|\n| Hamburger menu | Enter/Space | Toggle open/close |\n| Mobile nav drawer | Escape | Close |\n| Dropdown menus | Arrow Up/Down | Navigate items |\n| Dropdown menus | Escape | Close |\n| Cart drawer | Escape | Close |\n| Search modal | Escape | Close |\n| Search results | Arrow Up/Down | Navigate results |\n| Search results | Enter | Select highlighted result |\n| Variant radio buttons | Arrow Left/Right | Navigate options |\n| Quantity input | Arrow Up/Down | Increment/decrement |\n| Modals/dialogs | Tab/Shift+Tab | Cycle focus within (trap) |\n\n---\n\n## 16. Component Library\n\n### storefront-layout\n\nMaster layout wrapper.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| title | string | App name from config | Page title |\n| metaDescription | string | Empty | Meta description tag |\n\n### storefront-product-card\n\nRenders a single product card in grid contexts.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| product | Product model | Required | Product with variants, media, and inventory eager-loaded |\n| headingLevel | string | h3 | Heading tag for the product title |\n| showQuickAdd | boolean | true | Whether to show the quick add / choose options action |\n\n**Eager-load requirements:** product.variants.inventoryItem, product.media (sorted by position).\n\n### storefront-price\n\nDisplays a formatted price with optional compare-at price.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| amount | integer | Required | Price in minor units (cents) |\n| currency | string | USD | ISO 4217 currency code |\n| compareAtAmount | integer or null | null | Original price in minor units for sale display |\n\n**Rendering:** Formats amount using Laravel's Number currency helper. If compareAtAmount is set and greater than amount, shows compare-at price with strikethrough styling and a \"Sale\" badge.\n\n### storefront-badge\n\nRenders a styled badge/tag.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| text | string | Required | Badge text |\n| variant | string | default | Visual variant: sale, sold-out, new, default |\n\n**Variant Styles:**\n\n| Variant | Background | Text Color |\n|---|---|---|\n| sale | Light red (dark: dark red tint) | Red |\n| sold-out | Light gray (dark: dark gray) | Medium gray |\n| new | Light blue (dark: dark blue tint) | Blue |\n| default | Light gray (dark: dark gray) | Dark gray |\n\n**Base appearance:** Inline pill shape, rounded-full, extra-small medium-weight text with horizontal padding.\n\n### storefront-quantity-selector\n\nNumber input with increment/decrement buttons.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| value | integer | 1 | Current quantity |\n| min | integer | 1 | Minimum allowed value |\n| max | integer or null | null | Maximum allowed value (null = unlimited) |\n| wireModel | string | Required | Livewire model binding string |\n| compact | boolean | false | Smaller variant for cart drawer |\n\n### storefront-address-form\n\nRenders a full address form with all fields.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| address | array or null | null | Pre-filled address data |\n| prefix | string | Empty | Livewire model prefix (e.g., \"shipping\" for shipping.firstName) |\n\n### storefront-order-summary\n\nRenders the checkout order summary sidebar.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |\n| showDiscountInput | boolean | true | Whether to show the discount code input |\n\n### storefront-breadcrumbs\n\nRenders a breadcrumb navigation trail.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| items | array | Required | Array of objects with label and url. Last item has no URL (current page) |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Breadcrumb\"\n- Ordered list with separator characters between items\n- Current page (last item) has a \"current page\" indicator and distinct styling\n- Includes structured data (BreadcrumbList schema) for SEO\n\n### storefront-pagination\n\nWraps Laravel's pagination with custom styling.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| paginator | LengthAwarePaginator | Required | Laravel paginator instance |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Pagination\"\n- Previous/Next buttons with arrow icons\n- Numbered page buttons with current page highlighted\n- Ellipsis for truncated ranges\n- Mobile: previous/next only with \"Page X of Y\" text\n\n---\n\n## 17. Livewire Component Summary\n\n| Component | Location | Purpose |\n|---|---|---|\n| FeaturedProducts | Home page | Lazy-loaded featured products grid |\n| NewsletterSignup | Home page, footer | Email subscription form |\n| CollectionPage | Collection page | Filtering, sorting, pagination |\n| ProductPage | Product page | Variant selection, add to cart |\n| CartDrawer | Layout (global) | Slide-in cart with line management |\n| CartPage | Cart page | Full-page cart |\n| CheckoutPage | Checkout | Multi-step checkout flow |\n| SearchModal | Layout (global) | Autocomplete search overlay |\n| SearchResults | Search page | Full search results with filters |\n| Login | Login page | Authentication form |\n| Register | Register page | Registration form |\n| AddressBook | Addresses page | CRUD for saved addresses |\n\n---\n\n## 18. Browser Event Communication\n\nLivewire components communicate via dispatched browser events:\n\n| Event Name | Dispatched By | Listened By | Payload |\n|---|---|---|---|\n| cart-updated | ProductPage, CartDrawer, CartPage | CartDrawer (opens + refreshes), Header (badge count) | cartId, itemCount |\n| open-cart-drawer | Header cart icon click | CartDrawer | None |\n| close-cart-drawer | CartDrawer close actions | CartDrawer | None |\n| open-search-modal | Header search icon click | SearchModal | None |\n| close-search-modal | SearchModal close actions | SearchModal | None |\n| variant-changed | ProductPage (variant selector) | ProductPage (internal) | variantId, price, stock |\n\n---\n\n## 19. SEO and Structured Data\n\n### Meta Tags\n\nEach page type sets appropriate meta tags:\n\n| Page | Title Pattern | Description Source |\n|---|---|---|\n| Home | Store name | Theme settings |\n| Collection | \"{Collection title} - {Store name}\" | Collection description (stripped of markup) |\n| Product | \"{Product title} - {Store name}\" | Product description (stripped, truncated to 160 chars) |\n| Content page | \"{Page title} - {Store name}\" | Page body (stripped, truncated) |\n| Search | \"Search results - {Store name}\" | Static |\n\n### Open Graph Tags\n\nProduct pages include Open Graph tags for social sharing:\n\n| Tag | Value |\n|---|---|\n| og:title | Product title |\n| og:description | Meta description |\n| og:image | Primary product image URL |\n| og:type | product |\n| og:url | Current page URL |\n| product:price:amount | Formatted price (dollars) |\n| product:price:currency | Store currency code |\n\n### JSON-LD Structured Data\n\n| Schema Type | Used On |\n|---|---|\n| Product | Product pages |\n| BreadcrumbList | Breadcrumb navigation on all pages |\n| Organization | Footer (store info) |\n\n---\n\n## 20. User Flow Diagrams\n\n### Browse to Purchase Flow\n\n```mermaid\nflowchart TD\n    A[Home Page] --> B{Browse}\n    B --> C[Collection Page]\n    B --> D[Search Modal]\n    D --> E[Search Results Page]\n    C --> F[Product Page]\n    E --> F\n    F --> G{Select Variant}\n    G --> H[Set Quantity]\n    H --> I[Add to Cart]\n    I --> J[Cart Drawer Opens]\n    J --> K{Continue?}\n    K -->|Continue Shopping| B\n    K -->|Apply Discount| L[Enter Discount Code]\n    L --> J\n    K -->|Checkout| M[Checkout Page]\n    M --> N[Step 1: Contact Info]\n    N --> O[Step 2: Shipping Address]\n    O --> P[Step 3: Shipping Method]\n    P --> Q[Step 4: Payment]\n    Q --> R[Stripe/PayPal Redirect]\n    R --> S[Order Confirmation]\n    S --> T{Next?}\n    T -->|Continue Shopping| A\n    T -->|View Order| U[Account Order Detail]\n```\n\n### Account Management Flow\n\n```mermaid\nflowchart TD\n    A[Login Page] --> B{Has Account?}\n    B -->|No| C[Register Page]\n    C --> D[Account Dashboard]\n    B -->|Yes| D\n    D --> E{Action}\n    E --> F[Order History]\n    F --> G[Order Detail]\n    G --> H[Track Shipment]\n    E --> I[Address Book]\n    I --> J{Action}\n    J --> K[Add Address]\n    J --> L[Edit Address]\n    J --> M[Delete Address]\n    J --> N[Set Default Address]\n    E --> O[Log Out]\n```\n\n### Cart Management Flow\n\n```mermaid\nflowchart TD\n    A[Add to Cart] --> B[Cart Drawer Opens]\n    B --> C{Action}\n    C --> D[Update Quantity]\n    D --> B\n    C --> E[Remove Item]\n    E --> F{Cart Empty?}\n    F -->|Yes| G[Empty Cart State]\n    G --> H[Continue Shopping]\n    F -->|No| B\n    C --> I[Apply Discount Code]\n    I --> J{Valid?}\n    J -->|Yes| K[Discount Applied]\n    K --> B\n    J -->|No| L[Error Message]\n    L --> B\n    C --> M[Proceed to Checkout]\n    M --> N[Checkout Page]\n    C --> O[View Full Cart Page]\n    O --> N\n```\n\n### Component State Diagram\n\n```mermaid\nstateDiagram-v2\n    [*] --> Empty: Cart initialized\n\n    state \"Cart Drawer\" as CD {\n        Empty --> HasItems: Add item\n        HasItems --> HasItems: Update quantity\n        HasItems --> HasItems: Apply discount\n        HasItems --> Empty: Remove last item\n        HasItems --> Checkout: Proceed to checkout\n    }\n\n    state \"Product Page\" as PP {\n        NoVariant --> VariantSelected: Select options\n        VariantSelected --> InStock: Stock > 0\n        VariantSelected --> OutOfStock: Stock = 0\n        InStock --> Adding: Click add to cart\n        Adding --> InStock: Complete\n        OutOfStock --> SoldOut: Deny policy\n        OutOfStock --> Backorder: Continue policy\n    }\n\n    state \"Checkout\" as CO {\n        Contact --> ShippingAddress: Complete step 1\n        ShippingAddress --> ShippingMethod: Complete step 2\n        ShippingMethod --> Payment: Complete step 3\n        Payment --> Processing: Submit payment\n        Processing --> Confirmation: Payment success\n        Processing --> Payment: Payment failure\n    }\n```\n\n---\n\n## 21. Flash / Toast Messages\n\n| Action | Message | Type |\n|--------|---------|------|\n| Item added to cart | \"Added to cart\" | success |\n| Item removed from cart | \"Item removed\" | success |\n| Cart is empty | \"Your cart is empty\" | info |\n| Discount applied | \"Discount applied\" | success |\n| Invalid discount code | \"Invalid discount code\" | error |\n| Out of stock | \"This product is currently out of stock\" | warning |\n| Registration success | \"Account created successfully\" | success |\n| Login success | (redirect, no message) | - |\n| Login failed | \"Invalid credentials\" | error |\n| Password reset sent | \"We have emailed your password reset link!\" | success |\n| Order placed | \"Thank you for your order!\" | success |\n","structuredPatch":[{"oldStart":1202,"oldLines":8,"newStart":1202,"newLines":32,"lines":[" |---|---|"," | Items | List with thumbnail, title, variant info, quantity, price |"," | Address and payment | Two-column grid on desktop, stacked on mobile |","+| Payment method display | Shows \"Credit Card ending in {last4}\", \"PayPal\", or \"Bank Transfer\" depending on `order.payment_method` |"," | Totals | Right-aligned summary |"," ","+#### Bank Transfer Instructions (shown only when `payment_method = bank_transfer`)","+","+A prominent callout (info variant) displayed between the order details and the totals:","+","+```","++--------------------------------------------------------------+","+|  [info icon] Bank Transfer Instructions                       |","+|                                                               |","+|  Please transfer the total amount to the following account:   |","+|                                                               |","+|  Bank:     Mock Bank AG                                       |","+|  IBAN:     DE89 3704 0044 0532 0130 00                        |","+|  BIC:      COBADEFFXXX                                        |","+|  Amount:   29.98 EUR                                          |","+|  Reference: #1042                                             |","+|                                                               |","+|  Please complete your transfer within 7 days. Your order will |","+|  be processed once payment is confirmed by our team.          |","++--------------------------------------------------------------+","+```","+","+The reference field uses the order number so the admin can match incoming transfers.","+"," ### 9.3 Actions"," "," | Button | Description |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"22e7ec34-8565-4e20-b7fe-839b56e3e5b0"}
{"parentUuid":"40d9cf0c-a13c-4a17-81c4-382bec5e81cd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_013Cd1fiNFHo7mSvUCghkuCh","type":"message","role":"assistant","content":[{"type":"text","text":"Also update the checkout step detail table reference."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":588,"cache_read_input_tokens":121681,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":588},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WcGph6KyDrHWz5KEZj","type":"assistant","uuid":"e4612d61-7885-4bba-be32-f8baa424bd7d","timestamp":"2026-02-11T15:23:06.501Z"}
{"parentUuid":"e4612d61-7885-4bba-be32-f8baa424bd7d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_013Cd1fiNFHo7mSvUCghkuCh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CBExKArZSkvFpHyCvieY4S","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","offset":1082,"limit":20}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":588,"cache_read_input_tokens":121681,"output_tokens":123,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":588,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WcGph6KyDrHWz5KEZj","type":"assistant","uuid":"c4ba424a-47a4-4de6-8ed4-0bb8ebd43c2b","timestamp":"2026-02-11T15:23:07.382Z"}
{"parentUuid":"c4ba424a-47a4-4de6-8ed4-0bb8ebd43c2b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01CBExKArZSkvFpHyCvieY4S","toolUseID":"toolu_01CBExKArZSkvFpHyCvieY4S","timestamp":"2026-02-11T15:23:07.387Z","uuid":"eb2e7bf8-eead-46f7-9932-ea1c2a1af4d3"}
{"parentUuid":"c4ba424a-47a4-4de6-8ed4-0bb8ebd43c2b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CBExKArZSkvFpHyCvieY4S","type":"tool_result","content":"  1082→\n  1083→**PayPal form (shown when PayPal is selected):**\n  1084→\n  1085→```\n  1086→+--------------------------------------------------------------+\n  1087→| You will be redirected to complete your PayPal payment.       |\n  1088→| (mock: payment is processed instantly)                        |\n  1089→|                                                               |\n  1090→| [Pay with PayPal - 29.98 EUR]                                |\n  1091→+--------------------------------------------------------------+\n  1092→```\n  1093→\n  1094→No additional form fields. The button text changes to \"Pay with PayPal - {total}\".\n  1095→\n  1096→**Bank Transfer form (shown when Bank Transfer is selected):**\n  1097→\n  1098→```\n  1099→+--------------------------------------------------------------+\n  1100→| After placing your order, you will receive bank transfer      |\n  1101→| instructions. Your order will be held for 7 days while we     |"}]},"uuid":"d576141e-d92e-4c2e-bc8d-6f6bdb549e43","timestamp":"2026-02-11T15:23:07.386Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"\n**PayPal form (shown when PayPal is selected):**\n\n```\n+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Pay with PayPal - {total}\".\n\n**Bank Transfer form (shown when Bank Transfer is selected):**\n\n```\n+--------------------------------------------------------------+\n| After placing your order, you will receive bank transfer      |\n| instructions. Your order will be held for 7 days while we     |","numLines":20,"startLine":1082,"totalLines":1925}},"sourceToolAssistantUUID":"c4ba424a-47a4-4de6-8ed4-0bb8ebd43c2b"}
{"parentUuid":"d576141e-d92e-4c2e-bc8d-6f6bdb549e43","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KnfurPAuAqLvgx7zv88XSj","type":"message","role":"assistant","content":[{"type":"text","text":"Good. Now Task #5 is complete. Let me move to Task #6 (07-SEEDERS-AND-TEST-DATA.md)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":380,"cache_read_input_tokens":122269,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":380},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WcYPKUqnqhUqUUzi44","type":"assistant","uuid":"8c65ed9e-4010-46bf-a50e-c2246cff808d","timestamp":"2026-02-11T15:23:10.649Z"}
{"parentUuid":"8c65ed9e-4010-46bf-a50e-c2246cff808d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KnfurPAuAqLvgx7zv88XSj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KZ3PJg17wKcs6Pzt1PLcbL","name":"TaskUpdate","input":{"taskId":"5","status":"completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":380,"cache_read_input_tokens":122269,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":380},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WcYPKUqnqhUqUUzi44","type":"assistant","uuid":"09fb5b34-811b-45b2-919d-bc8ce5285d22","timestamp":"2026-02-11T15:23:11.100Z"}
{"parentUuid":"09fb5b34-811b-45b2-919d-bc8ce5285d22","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KZ3PJg17wKcs6Pzt1PLcbL","type":"tool_result","content":"Updated task #5 status"}]},"uuid":"d237df88-43ca-4bc0-baf6-d38e2f6647a6","timestamp":"2026-02-11T15:23:11.104Z","toolUseResult":{"success":true,"taskId":"5","updatedFields":["status"],"statusChange":{"from":"in_progress","to":"completed"}},"sourceToolAssistantUUID":"09fb5b34-811b-45b2-919d-bc8ce5285d22"}
{"parentUuid":"d237df88-43ca-4bc0-baf6-d38e2f6647a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KnfurPAuAqLvgx7zv88XSj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016JGDXsDUtGsc7XVLTbUwqs","name":"TaskUpdate","input":{"taskId":"6","status":"in_progress"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":380,"cache_read_input_tokens":122269,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":380},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WcYPKUqnqhUqUUzi44","type":"assistant","uuid":"23c09993-248c-4892-8373-e888ca8e7248","timestamp":"2026-02-11T15:23:11.555Z"}
{"parentUuid":"23c09993-248c-4892-8373-e888ca8e7248","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016JGDXsDUtGsc7XVLTbUwqs","type":"tool_result","content":"Updated task #6 status"}]},"uuid":"faeeeb75-2a9d-4f18-ae95-2aae1921490b","timestamp":"2026-02-11T15:23:11.558Z","toolUseResult":{"success":true,"taskId":"6","updatedFields":["status"],"statusChange":{"from":"pending","to":"in_progress"}},"sourceToolAssistantUUID":"23c09993-248c-4892-8373-e888ca8e7248"}
{"parentUuid":"faeeeb75-2a9d-4f18-ae95-2aae1921490b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_019hWnCy2CfADwxezbYvXHPZ","type":"message","role":"assistant","content":[{"type":"text","text":"For 07-SEEDERS-AND-TEST-DATA.md, I need to:\n1. Add `payment_method` to CheckoutFactory\n2. Add `payment_method` to OrderFactory\n3. Update PaymentFactory (provider \"stripe\" -> \"mock\", add method field, remove requiresAction/authorized states)\n4. Update all 15 order seeds to include payment_method\n5. Change Order #1005 to be a bank_transfer pending order\n6. Add a PayPal order"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":232,"cache_read_input_tokens":122649,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":232},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WcrTkySfRWFdBMNmek","type":"assistant","uuid":"9823d8f4-a1f5-4636-88cb-a48dce5dbe3f","timestamp":"2026-02-11T15:23:16.699Z"}
{"type":"file-history-snapshot","messageId":"56903520-598e-4903-96d5-cfaea589ae1c","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v1","version":1,"backupTime":"2026-02-11T15:14:23.948Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v1","version":1,"backupTime":"2026-02-11T15:16:45.432Z"},"specs/03-ADMIN-UI.md":{"backupFileName":"a791bee027d52553@v1","version":1,"backupTime":"2026-02-11T15:21:51.319Z"},"specs/04-STOREFRONT-UI.md":{"backupFileName":"401411061d8a6ac1@v1","version":1,"backupTime":"2026-02-11T15:22:26.455Z"},"specs/07-SEEDERS-AND-TEST-DATA.md":{"backupFileName":"c11f09e3e5d3a009@v1","version":1,"backupTime":"2026-02-11T15:23:20.806Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"9823d8f4-a1f5-4636-88cb-a48dce5dbe3f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_019hWnCy2CfADwxezbYvXHPZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EKeGR2C531Mvk4BDGhWV9f","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |","new_string":"| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":232,"cache_read_input_tokens":122649,"output_tokens":502,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":232,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WcrTkySfRWFdBMNmek","type":"assistant","uuid":"56903520-598e-4903-96d5-cfaea589ae1c","timestamp":"2026-02-11T15:23:20.802Z"}
{"parentUuid":"56903520-598e-4903-96d5-cfaea589ae1c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01EKeGR2C531Mvk4BDGhWV9f","toolUseID":"toolu_01EKeGR2C531Mvk4BDGhWV9f","timestamp":"2026-02-11T15:23:20.809Z","uuid":"62f1a3f5-f495-42f8-9600-613192d55019"}
{"parentUuid":"56903520-598e-4903-96d5-cfaea589ae1c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EKeGR2C531Mvk4BDGhWV9f","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"9ed95f94-d664-43be-bafa-b89dafcfef41","timestamp":"2026-02-11T15:23:20.809Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |","newString":"| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"stripe\" |\n| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `requiresAction` | status = \"requires_action\" |\n| `authorized` | status = \"authorized\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":331,"oldLines":6,"newStart":331,"newLines":7,"lines":[" | shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |"," | billing_address_json | null |"," | shipping_method_id | null |","+| payment_method | null |"," | discount_code | null |"," | tax_provider_snapshot_json | null |"," | totals_json | null |"]},{"oldStart":342,"oldLines":6,"newStart":343,"newLines":9,"lines":[" |-----------|-----------|"," | `completed` | status = \"completed\" |"," | `expired` | status = \"expired\", expires_at = 1 hour ago |","+| `withCreditCard` | payment_method = \"credit_card\" |","+| `withPaypal` | payment_method = \"paypal\" |","+| `withBankTransfer` | payment_method = \"bank_transfer\" |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"56903520-598e-4903-96d5-cfaea589ae1c"}
{"parentUuid":"9ed95f94-d664-43be-bafa-b89dafcfef41","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01ALifs6eii44K5fZ3o1ncsY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01J9x9WEtvaLdzXJDBKaWnw8","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |","new_string":"| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":550,"cache_read_input_tokens":122881,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":550},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WdXmtWXpiPZEcy2bXm","type":"assistant","uuid":"e9cdd3d0-12d3-4cd9-b106-a7ac9a6155d5","timestamp":"2026-02-11T15:23:31.281Z"}
{"parentUuid":"e9cdd3d0-12d3-4cd9-b106-a7ac9a6155d5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01J9x9WEtvaLdzXJDBKaWnw8","toolUseID":"toolu_01J9x9WEtvaLdzXJDBKaWnw8","timestamp":"2026-02-11T15:23:31.286Z","uuid":"7f59eca2-da90-4294-a662-5a0359d9a3f8"}
{"parentUuid":"e9cdd3d0-12d3-4cd9-b106-a7ac9a6155d5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01J9x9WEtvaLdzXJDBKaWnw8","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"98a42d80-71ad-4674-a7c7-29388ada26aa","timestamp":"2026-02-11T15:23:31.286Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |","newString":"| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"stripe\" |\n| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `requiresAction` | status = \"requires_action\" |\n| `authorized` | status = \"authorized\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":360,"oldLines":6,"newStart":360,"newLines":7,"lines":[" | store_id | Creates a new Store via its factory |"," | customer_id | Creates a new Customer via its factory |"," | order_number | \"#\" + Faker unique integer 1001-9999 |","+| payment_method | \"credit_card\" |"," | status | \"paid\" |"," | financial_status | \"paid\" |"," | fulfillment_status | \"unfulfilled\" |"]},{"oldStart":379,"oldLines":10,"newStart":380,"newLines":14,"lines":[" | State Name | Overrides |"," |-----------|-----------|"," | `pending` | status = \"pending\", financial_status = \"pending\" |","+| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |"," | `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |"," | `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |"," | `refunded` | status = \"refunded\", financial_status = \"refunded\" |"," | `partiallyFulfilled` | fulfillment_status = \"partial\" |","+| `creditCard` | payment_method = \"credit_card\" |","+| `paypal` | payment_method = \"paypal\" |","+| `bankTransfer` | payment_method = \"bank_transfer\" |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"e9cdd3d0-12d3-4cd9-b106-a7ac9a6155d5"}
{"parentUuid":"98a42d80-71ad-4674-a7c7-29388ada26aa","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BM4NLYJgzPnQfvpMo9Ubt6","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the PaymentFactory."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":911,"cache_read_input_tokens":123431,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":911},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WeJo4bLeigkc18QfnJ","type":"assistant","uuid":"e0bd5c9a-5874-4732-b4d8-f33808251c53","timestamp":"2026-02-11T15:23:34.186Z"}
{"parentUuid":"e0bd5c9a-5874-4732-b4d8-f33808251c53","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BM4NLYJgzPnQfvpMo9Ubt6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TZvEm3sk9DuX67R3m7FrnQ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"stripe\" |\n| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `requiresAction` | status = \"requires_action\" |\n| `authorized` | status = \"authorized\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |","new_string":"### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":911,"cache_read_input_tokens":123431,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":911},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WeJo4bLeigkc18QfnJ","type":"assistant","uuid":"478845fc-e0e4-4704-b2d2-674868299b8b","timestamp":"2026-02-11T15:23:39.911Z"}
{"parentUuid":"478845fc-e0e4-4704-b2d2-674868299b8b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01TZvEm3sk9DuX67R3m7FrnQ","toolUseID":"toolu_01TZvEm3sk9DuX67R3m7FrnQ","timestamp":"2026-02-11T15:23:39.915Z","uuid":"efd6b84e-7db9-40dd-af11-fa4d045d3ef8"}
{"parentUuid":"478845fc-e0e4-4704-b2d2-674868299b8b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TZvEm3sk9DuX67R3m7FrnQ","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"0d482bc3-f689-4e3f-9e41-5efdf12f2a4a","timestamp":"2026-02-11T15:23:39.915Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"stripe\" |\n| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `requiresAction` | status = \"requires_action\" |\n| `authorized` | status = \"authorized\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |","newString":"### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"stripe\" |\n| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `requiresAction` | status = \"requires_action\" |\n| `authorized` | status = \"authorized\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":423,"oldLines":8,"newStart":423,"newLines":9,"lines":[" | Field | Generation Rule |"," |-------|----------------|"," | order_id | Creates a new Order via its factory |","-| provider | \"stripe\" |","-| provider_payment_id | \"pi_\" + Faker 20 random alphanumeric characters |","+| provider | \"mock\" |","+| method | \"credit_card\" |","+| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |"," | status | \"captured\" |"," | amount | Random 999-99999 |"," | currency | \"EUR\" |"]},{"oldStart":434,"oldLines":10,"newStart":435,"newLines":12,"lines":[" "," | State Name | Overrides |"," |-----------|-----------|","-| `requiresAction` | status = \"requires_action\" |","-| `authorized` | status = \"authorized\" |","+| `pending` | status = \"pending\" |"," | `failed` | status = \"failed\" |"," | `refunded` | status = \"refunded\" |","+| `creditCard` | method = \"credit_card\" |","+| `paypal` | method = \"paypal\" |","+| `bankTransfer` | method = \"bank_transfer\" |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"478845fc-e0e4-4704-b2d2-674868299b8b"}
{"parentUuid":"0d482bc3-f689-4e3f-9e41-5efdf12f2a4a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018aeyW4U2KHbustjymRyc1u","type":"message","role":"assistant","content":[{"type":"text","text":"Now update all the seeded orders. I need to add `payment_method` to each and change the payment provider references from \"stripe\" to \"mock\". I'll update them in batches."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":612,"cache_read_input_tokens":124342,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":612},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WewecFDaRbSJVj8rCC","type":"assistant","uuid":"a841a62a-14fa-4412-bedd-d92e37eddb5f","timestamp":"2026-02-11T15:23:43.429Z"}
{"parentUuid":"a841a62a-14fa-4412-bedd-d92e37eddb5f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_018aeyW4U2KHbustjymRyc1u","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0182VPW6y2zdivASZiS6fS8G","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497","new_string":"**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":612,"cache_read_input_tokens":124342,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":612},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WewecFDaRbSJVj8rCC","type":"assistant","uuid":"1f6a245c-3ac4-4f57-9b3c-60bac070c0fb","timestamp":"2026-02-11T15:23:49.769Z"}
{"parentUuid":"1f6a245c-3ac4-4f57-9b3c-60bac070c0fb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_0182VPW6y2zdivASZiS6fS8G","toolUseID":"toolu_0182VPW6y2zdivASZiS6fS8G","timestamp":"2026-02-11T15:23:49.776Z","uuid":"15788828-d6fe-48e9-90f7-e4075e191d38"}
{"parentUuid":"1f6a245c-3ac4-4f57-9b3c-60bac070c0fb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0182VPW6y2zdivASZiS6fS8G","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"97115371-c966-419a-b758-cd0aa3acd8f7","timestamp":"2026-02-11T15:23:49.775Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497","newString":"**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1504,"oldLines":11,"newStart":1504,"newLines":12,"lines":[" "," ---"," ","-**Order #1001 - Awaiting fulfillment**","+**Order #1001 - Awaiting fulfillment (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | customer@acme.test (John Doe) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / paid / unfulfilled |"," | placed_at | 2 days ago |"," "]},{"oldStart":1520,"oldLines":7,"newStart":1521,"newLines":7,"lines":[" "," Totals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**"," ","-Payment: stripe, provider_payment_id \"pi_test_order1001\", status \"captured\", amount 5497","+Payment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497"," "," Fulfillments: None."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"1f6a245c-3ac4-4f57-9b3c-60bac070c0fb"}
{"parentUuid":"97115371-c966-419a-b758-cd0aa3acd8f7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GVfynqGMs7oNkCmTWaQ62X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G3JS1e2PtKFdfAuBxF19ZS","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |","new_string":"**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":624,"cache_read_input_tokens":124954,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":624},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WffxKRtcb3Q79c5sM3","type":"assistant","uuid":"0f844c10-43cb-451b-aad4-36631e066549","timestamp":"2026-02-11T15:23:55.241Z"}
{"parentUuid":"0f844c10-43cb-451b-aad4-36631e066549","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01G3JS1e2PtKFdfAuBxF19ZS","toolUseID":"toolu_01G3JS1e2PtKFdfAuBxF19ZS","timestamp":"2026-02-11T15:23:55.247Z","uuid":"acffce4f-8a1e-4d19-86de-ffa8d419bfb6"}
{"parentUuid":"0f844c10-43cb-451b-aad4-36631e066549","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01G3JS1e2PtKFdfAuBxF19ZS","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"4592edb3-6ce0-47de-ad91-15378faed54d","timestamp":"2026-02-11T15:23:55.246Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |","newString":"**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1527,"oldLines":11,"newStart":1527,"newLines":12,"lines":[" "," ---"," ","-**Order #1002 - Fully delivered**","+**Order #1002 - Fully delivered (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | customer@acme.test (John Doe) |","+| payment_method | credit_card |"," | status / financial / fulfillment | fulfilled / paid / fulfilled |"," | placed_at | 10 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"0f844c10-43cb-451b-aad4-36631e066549"}
{"parentUuid":"4592edb3-6ce0-47de-ad91-15378faed54d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GVfynqGMs7oNkCmTWaQ62X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ShUVS613uHkS6u5NkH7zZa","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, \"pi_test_order1002\", captured, 8997","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":624,"cache_read_input_tokens":124954,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":624},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WffxKRtcb3Q79c5sM3","type":"assistant","uuid":"18f1b98a-32d4-4f26-b24c-b506bfb768c8","timestamp":"2026-02-11T15:23:55.779Z"}
{"parentUuid":"18f1b98a-32d4-4f26-b24c-b506bfb768c8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01ShUVS613uHkS6u5NkH7zZa","toolUseID":"toolu_01ShUVS613uHkS6u5NkH7zZa","timestamp":"2026-02-11T15:23:55.784Z","uuid":"f80cdc81-1e49-4504-9922-fd909718d579"}
{"parentUuid":"18f1b98a-32d4-4f26-b24c-b506bfb768c8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ShUVS613uHkS6u5NkH7zZa","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"22f62466-ea1e-4825-8da8-641a317ce45a","timestamp":"2026-02-11T15:23:55.784Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, \"pi_test_order1002\", captured, 8997","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, \"pi_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1545,"oldLines":7,"newStart":1545,"newLines":7,"lines":[" "," Totals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**"," ","-Payment: stripe, \"pi_test_order1002\", captured, 8997","+Payment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997"," "," Fulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"18f1b98a-32d4-4f26-b24c-b506bfb768c8"}
{"parentUuid":"22f62466-ea1e-4825-8da8-641a317ce45a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SoUPYsR1Zw4CJMNZk91bhd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E7NFU7Wb4GXQrUHErjhioe","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |","new_string":"**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":577,"cache_read_input_tokens":125578,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":577},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wg7Xi1jEpagHaSJFNA","type":"assistant","uuid":"dfa525a3-3149-4bdc-b50e-7b63f4dceaf7","timestamp":"2026-02-11T15:24:00.223Z"}
{"parentUuid":"dfa525a3-3149-4bdc-b50e-7b63f4dceaf7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01E7NFU7Wb4GXQrUHErjhioe","toolUseID":"toolu_01E7NFU7Wb4GXQrUHErjhioe","timestamp":"2026-02-11T15:24:00.231Z","uuid":"4a10e094-7d9a-468d-93ed-74d185113f19"}
{"parentUuid":"dfa525a3-3149-4bdc-b50e-7b63f4dceaf7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E7NFU7Wb4GXQrUHErjhioe","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"dce9a4b4-d0ac-49ac-84c3-e378004cd9df","timestamp":"2026-02-11T15:24:00.230Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |","newString":"**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1551,"oldLines":11,"newStart":1551,"newLines":12,"lines":[" "," ---"," ","-**Order #1003 - Partially fulfilled**","+**Order #1003 - Partially fulfilled (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | jane@example.com (Jane Smith) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / paid / partial |"," | placed_at | 5 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"dfa525a3-3149-4bdc-b50e-7b63f4dceaf7"}
{"parentUuid":"dce9a4b4-d0ac-49ac-84c3-e378004cd9df","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SoUPYsR1Zw4CJMNZk91bhd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MaVNHgrTTBp64SRNhWy2ag","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, \"pi_test_order1003\", captured, 11997","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":577,"cache_read_input_tokens":125578,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":577},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wg7Xi1jEpagHaSJFNA","type":"assistant","uuid":"f7b12af4-a7a3-40de-9799-6d88aab628db","timestamp":"2026-02-11T15:24:01.478Z"}
{"parentUuid":"f7b12af4-a7a3-40de-9799-6d88aab628db","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01MaVNHgrTTBp64SRNhWy2ag","toolUseID":"toolu_01MaVNHgrTTBp64SRNhWy2ag","timestamp":"2026-02-11T15:24:01.484Z","uuid":"1b550ec5-1608-4111-b3f0-0ef435a25d6a"}
{"parentUuid":"f7b12af4-a7a3-40de-9799-6d88aab628db","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MaVNHgrTTBp64SRNhWy2ag","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"0334079f-fe19-4ecf-a690-a626ad58b5fe","timestamp":"2026-02-11T15:24:01.483Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, \"pi_test_order1003\", captured, 11997","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: stripe, \"pi_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1569,"oldLines":7,"newStart":1569,"newLines":7,"lines":[" "," Totals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**"," ","-Payment: stripe, \"pi_test_order1003\", captured, 11997","+Payment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997"," "," Fulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"f7b12af4-a7a3-40de-9799-6d88aab628db"}
{"parentUuid":"0334079f-fe19-4ecf-a690-a626ad58b5fe","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017Tu272UKteZwCpTBn8b39e","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Q5GYw5aJpQtfJiCSfh2EmJ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |","new_string":"**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":899,"cache_read_input_tokens":126155,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":899},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WgXv9uc5yYuhmzdeTh","type":"assistant","uuid":"09c9724e-9744-43e3-bfca-e71624a17542","timestamp":"2026-02-11T15:24:06.522Z"}
{"parentUuid":"09c9724e-9744-43e3-bfca-e71624a17542","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Q5GYw5aJpQtfJiCSfh2EmJ","toolUseID":"toolu_01Q5GYw5aJpQtfJiCSfh2EmJ","timestamp":"2026-02-11T15:24:06.527Z","uuid":"ab430ff0-29e6-4d50-bbba-4b704284f7b6"}
{"parentUuid":"09c9724e-9744-43e3-bfca-e71624a17542","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Q5GYw5aJpQtfJiCSfh2EmJ","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"5d1254fb-207c-4a04-9bda-219a85f27be4","timestamp":"2026-02-11T15:24:06.526Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |","newString":"**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1575,"oldLines":11,"newStart":1575,"newLines":12,"lines":[" "," ---"," ","-**Order #1004 - Cancelled with full refund**","+**Order #1004 - Cancelled with full refund (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | customer@acme.test (John Doe) |","+| payment_method | credit_card |"," | status / financial / fulfillment | cancelled / refunded / unfulfilled |"," | placed_at | 15 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"09c9724e-9744-43e3-bfca-e71624a17542"}
{"parentUuid":"5d1254fb-207c-4a04-9bda-219a85f27be4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_017Tu272UKteZwCpTBn8b39e","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Q55X3Remp7o3yF49NZ5HUq","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":899,"cache_read_input_tokens":126155,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":899},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WgXv9uc5yYuhmzdeTh","type":"assistant","uuid":"2c7691e6-c0c7-4eb2-9abf-efee9c57f5ef","timestamp":"2026-02-11T15:24:07.892Z"}
{"parentUuid":"2c7691e6-c0c7-4eb2-9abf-efee9c57f5ef","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Q55X3Remp7o3yF49NZ5HUq","toolUseID":"toolu_01Q55X3Remp7o3yF49NZ5HUq","timestamp":"2026-02-11T15:24:07.897Z","uuid":"bb48cfe0-06e1-4a8f-aab3-fe6f031eed34"}
{"parentUuid":"2c7691e6-c0c7-4eb2-9abf-efee9c57f5ef","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Q55X3Remp7o3yF49NZ5HUq","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"91f9c554-a143-4333-bd60-b5bd72755b7f","timestamp":"2026-02-11T15:24:07.897Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1592,"oldLines":7,"newStart":1592,"newLines":7,"lines":[" "," Totals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**"," ","-Payment: stripe, \"pi_test_order1004\", status \"refunded\", amount 2998","+Payment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998"," "," Refund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\""," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"2c7691e6-c0c7-4eb2-9abf-efee9c57f5ef"}
{"parentUuid":"91f9c554-a143-4333-bd60-b5bd72755b7f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01VkxP7yujmdjbvUQsmhGDEg","type":"message","role":"assistant","content":[{"type":"text","text":"Now update Order #1005 to be a bank transfer pending order (was previously \"payment pending\" with Stripe requires_action)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":597,"cache_read_input_tokens":127054,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":597},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wh1NQCytc5SYFoZmGk","type":"assistant","uuid":"d4c76ec8-1efc-4ee8-ab9f-780a6e0dc5c9","timestamp":"2026-02-11T15:24:10.934Z"}
{"parentUuid":"d4c76ec8-1efc-4ee8-ab9f-780a6e0dc5c9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01VkxP7yujmdjbvUQsmhGDEg","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015o1BxD3EjLLAKk62yjVuvD","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998","new_string":"**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":597,"cache_read_input_tokens":127054,"output_tokens":569,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":597,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2Wh1NQCytc5SYFoZmGk","type":"assistant","uuid":"0a294eb3-952a-4393-9574-f9521862a4e4","timestamp":"2026-02-11T15:24:16.811Z"}
{"parentUuid":"0a294eb3-952a-4393-9574-f9521862a4e4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_015o1BxD3EjLLAKk62yjVuvD","toolUseID":"toolu_015o1BxD3EjLLAKk62yjVuvD","timestamp":"2026-02-11T15:24:16.820Z","uuid":"f577cc5e-7ca8-476a-a17f-9584eccec9f4"}
{"parentUuid":"0a294eb3-952a-4393-9574-f9521862a4e4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015o1BxD3EjLLAKk62yjVuvD","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"bcb92d51-5433-4dd4-8a2c-937be55d9643","timestamp":"2026-02-11T15:24:16.819Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998","newString":"**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Payment pending**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1598,"oldLines":11,"newStart":1598,"newLines":12,"lines":[" "," ---"," ","-**Order #1005 - Payment pending**","+**Order #1005 - Bank transfer awaiting payment**"," "," | Field | Value |"," |-------|-------|"," | customer | jane@example.com (Jane Smith) |","+| payment_method | bank_transfer |"," | status / financial / fulfillment | pending / pending / unfulfilled |"," | placed_at | 2 hours ago |"," "]},{"oldStart":1614,"oldLines":8,"newStart":1615,"newLines":10,"lines":[" "," Totals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**"," ","-Payment: stripe, \"pi_test_order1005\", status \"requires_action\", amount 3998","+Payment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998"," ","+**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.","+"," ---"," "," **Order #1006 - Standard paid order**"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"0a294eb3-952a-4393-9574-f9521862a4e4"}
{"parentUuid":"bcb92d51-5433-4dd4-8a2c-937be55d9643","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RCtdSpMXw5kuMWMVh7aSwn","type":"message","role":"assistant","content":[{"type":"text","text":"Now update orders #1006-#1015. Let me do these in batches."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":617,"cache_read_input_tokens":127651,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":617},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WhfFxSBdhR9L8ncNZS","type":"assistant","uuid":"e96e9002-53d7-4b8c-a802-e10fe064f1f5","timestamp":"2026-02-11T15:24:19.982Z"}
{"parentUuid":"e96e9002-53d7-4b8c-a802-e10fe064f1f5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RCtdSpMXw5kuMWMVh7aSwn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01U2UP7dNRPqkFsxNPNya8qN","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","new_string":"**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":617,"cache_read_input_tokens":127651,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":617},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WhfFxSBdhR9L8ncNZS","type":"assistant","uuid":"86819d4e-07c0-4c63-a6b2-d7fd44707f43","timestamp":"2026-02-11T15:24:22.137Z"}
{"parentUuid":"86819d4e-07c0-4c63-a6b2-d7fd44707f43","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01U2UP7dNRPqkFsxNPNya8qN","toolUseID":"toolu_01U2UP7dNRPqkFsxNPNya8qN","timestamp":"2026-02-11T15:24:22.143Z","uuid":"7bbada0e-49d2-4d28-af6b-829ac7126f4f"}
{"parentUuid":"86819d4e-07c0-4c63-a6b2-d7fd44707f43","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01U2UP7dNRPqkFsxNPNya8qN","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"3ef1e1fd-863c-41c2-a274-eff9c135aaab","timestamp":"2026-02-11T15:24:22.142Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","newString":"**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1621,"oldLines":11,"newStart":1621,"newLines":12,"lines":[" "," ---"," ","-**Order #1006 - Standard paid order**","+**Order #1006 - Standard paid order (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | michael@example.com (Michael Brown) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / paid / unfulfilled |"," | placed_at | 1 day ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"86819d4e-07c0-4c63-a6b2-d7fd44707f43"}
{"parentUuid":"3ef1e1fd-863c-41c2-a274-eff9c135aaab","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RCtdSpMXw5kuMWMVh7aSwn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XbTWnUBofL85gvJDDg2agY","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, \"pi_test_order1006\", captured, 12498","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":617,"cache_read_input_tokens":127651,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":617},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WhfFxSBdhR9L8ncNZS","type":"assistant","uuid":"85b1494b-8f60-444f-9659-038762b55ab8","timestamp":"2026-02-11T15:24:23.345Z"}
{"parentUuid":"85b1494b-8f60-444f-9659-038762b55ab8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01XbTWnUBofL85gvJDDg2agY","toolUseID":"toolu_01XbTWnUBofL85gvJDDg2agY","timestamp":"2026-02-11T15:24:23.349Z","uuid":"a869a9cf-3060-4c2d-af74-7e6fbf69c64e"}
{"parentUuid":"85b1494b-8f60-444f-9659-038762b55ab8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XbTWnUBofL85gvJDDg2agY","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"383b0026-ac99-4ff0-808c-47b8e72b0be0","timestamp":"2026-02-11T15:24:23.349Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, \"pi_test_order1006\", captured, 12498","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: stripe, \"pi_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1638,"oldLines":7,"newStart":1638,"newLines":7,"lines":[" "," Totals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**"," ","-Payment: stripe, \"pi_test_order1006\", captured, 12498","+Payment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"85b1494b-8f60-444f-9659-038762b55ab8"}
{"parentUuid":"383b0026-ac99-4ff0-808c-47b8e72b0be0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RCtdSpMXw5kuMWMVh7aSwn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BiVGZARtJZpaeZoLR45Du7","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |","new_string":"**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":617,"cache_read_input_tokens":127651,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":617},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WhfFxSBdhR9L8ncNZS","type":"assistant","uuid":"3737c518-afa3-4e7e-b848-290f3b5736f1","timestamp":"2026-02-11T15:24:25.478Z"}
{"parentUuid":"3737c518-afa3-4e7e-b848-290f3b5736f1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01BiVGZARtJZpaeZoLR45Du7","toolUseID":"toolu_01BiVGZARtJZpaeZoLR45Du7","timestamp":"2026-02-11T15:24:25.482Z","uuid":"4a2032c7-936d-4f3c-8f53-40b2ca01eff6"}
{"parentUuid":"3737c518-afa3-4e7e-b848-290f3b5736f1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BiVGZARtJZpaeZoLR45Du7","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"26d1af6d-d179-4a9e-85eb-320078917915","timestamp":"2026-02-11T15:24:25.482Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |","newString":"**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1642,"oldLines":11,"newStart":1642,"newLines":12,"lines":[" "," ---"," ","-**Order #1007 - Multi-item delivered**","+**Order #1007 - Multi-item delivered (PayPal)**"," "," | Field | Value |"," |-------|-------|"," | customer | sarah@example.com (Sarah Wilson) |","+| payment_method | paypal |"," | status / financial / fulfillment | fulfilled / paid / fulfilled |"," | placed_at | 20 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"3737c518-afa3-4e7e-b848-290f3b5736f1"}
{"parentUuid":"26d1af6d-d179-4a9e-85eb-320078917915","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RCtdSpMXw5kuMWMVh7aSwn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SfFmATT9AypYqJZEvy88Nf","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 10496","new_string":"Payment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":617,"cache_read_input_tokens":127651,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":617},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WhfFxSBdhR9L8ncNZS","type":"assistant","uuid":"1fc62060-6241-4c87-b211-4876ef27bb6c","timestamp":"2026-02-11T15:24:26.701Z"}
{"parentUuid":"1fc62060-6241-4c87-b211-4876ef27bb6c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01SfFmATT9AypYqJZEvy88Nf","toolUseID":"toolu_01SfFmATT9AypYqJZEvy88Nf","timestamp":"2026-02-11T15:24:26.707Z","uuid":"af1a28fc-f13a-42fe-b97d-21e5aa736f2b"}
{"parentUuid":"1fc62060-6241-4c87-b211-4876ef27bb6c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SfFmATT9AypYqJZEvy88Nf","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"277f2313-4635-4908-bf77-b3d3d9e88ff9","timestamp":"2026-02-11T15:24:26.706Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 10496","newString":"Payment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: stripe, captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1660,"oldLines":7,"newStart":1660,"newLines":7,"lines":[" "," Totals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**"," ","-Payment: stripe, captured, 10496","+Payment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496"," "," Fulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"1fc62060-6241-4c87-b211-4876ef27bb6c"}
{"parentUuid":"277f2313-4635-4908-bf77-b3d3d9e88ff9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014pp1kviiw8V4R6iVAXA3QB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ETboRLdLTi16UfUBm5VArG","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |","new_string":"**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1132,"cache_read_input_tokens":128268,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1132},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WiPnJzYYR7sfkcWRRR","type":"assistant","uuid":"168c37cd-6fd2-4efd-8633-b55f2d22204e","timestamp":"2026-02-11T15:24:31.129Z"}
{"parentUuid":"168c37cd-6fd2-4efd-8633-b55f2d22204e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01ETboRLdLTi16UfUBm5VArG","toolUseID":"toolu_01ETboRLdLTi16UfUBm5VArG","timestamp":"2026-02-11T15:24:31.136Z","uuid":"bd93adb8-ef68-4988-ac43-5664167088eb"}
{"parentUuid":"168c37cd-6fd2-4efd-8633-b55f2d22204e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ETboRLdLTi16UfUBm5VArG","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"4e6be629-77b8-4ac6-b7f2-56d39bb8cfd1","timestamp":"2026-02-11T15:24:31.135Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |","newString":"**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1666,"oldLines":11,"newStart":1666,"newLines":12,"lines":[" "," ---"," ","-**Order #1008 - Partial refund**","+**Order #1008 - Partial refund (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | david@example.com (David Lee) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / partially_refunded / fulfilled |"," | placed_at | 12 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"168c37cd-6fd2-4efd-8633-b55f2d22204e"}
{"parentUuid":"4e6be629-77b8-4ac6-b7f2-56d39bb8cfd1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014pp1kviiw8V4R6iVAXA3QB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016Zw2cj1yYYuyyqjFkXvxNR","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\""}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1132,"cache_read_input_tokens":128268,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1132},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WiPnJzYYR7sfkcWRRR","type":"assistant","uuid":"cf4a10a9-a05a-4301-abb5-37ca1e0e426c","timestamp":"2026-02-11T15:24:35.083Z"}
{"parentUuid":"cf4a10a9-a05a-4301-abb5-37ca1e0e426c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_016Zw2cj1yYYuyyqjFkXvxNR","toolUseID":"toolu_016Zw2cj1yYYuyyqjFkXvxNR","timestamp":"2026-02-11T15:24:35.088Z","uuid":"f0275fbb-be62-4c09-bc64-71112915f7aa"}
{"parentUuid":"cf4a10a9-a05a-4301-abb5-37ca1e0e426c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016Zw2cj1yYYuyyqjFkXvxNR","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"a66ffbcb-fc17-4252-9ef2-a3f23e19f658","timestamp":"2026-02-11T15:24:35.087Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: stripe, captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1684,"oldLines":11,"newStart":1684,"newLines":11,"lines":[" "," Totals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**"," ","-Payment: stripe, captured, 8997","+Payment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997"," "," Fulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago"," ","-Refund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"re_test_order1008\"","+Refund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\""," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"cf4a10a9-a05a-4301-abb5-37ca1e0e426c"}
{"parentUuid":"a66ffbcb-fc17-4252-9ef2-a3f23e19f658","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014pp1kviiw8V4R6iVAXA3QB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CMeAnWVM2VRMnuqdxVHYz5","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |","new_string":"**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1132,"cache_read_input_tokens":128268,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1132},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WiPnJzYYR7sfkcWRRR","type":"assistant","uuid":"4a1af552-f14e-4e10-9bf3-e99a19b80044","timestamp":"2026-02-11T15:24:37.197Z"}
{"parentUuid":"4a1af552-f14e-4e10-9bf3-e99a19b80044","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01CMeAnWVM2VRMnuqdxVHYz5","toolUseID":"toolu_01CMeAnWVM2VRMnuqdxVHYz5","timestamp":"2026-02-11T15:24:37.207Z","uuid":"34ca7973-7596-421b-9656-d56c49fa7cbf"}
{"parentUuid":"4a1af552-f14e-4e10-9bf3-e99a19b80044","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CMeAnWVM2VRMnuqdxVHYz5","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"655c4f53-a01a-49ae-bb40-38b847e1e040","timestamp":"2026-02-11T15:24:37.206Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |","newString":"**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1692,"oldLines":11,"newStart":1692,"newLines":12,"lines":[" "," ---"," ","-**Order #1009 - Accessories order**","+**Order #1009 - Accessories order (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | emma@example.com (Emma Garcia) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / paid / unfulfilled |"," | placed_at | 3 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"4a1af552-f14e-4e10-9bf3-e99a19b80044"}
{"parentUuid":"655c4f53-a01a-49ae-bb40-38b847e1e040","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014pp1kviiw8V4R6iVAXA3QB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ULHiH8Uyhnot3etm5Pn5d9","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 4997","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1132,"cache_read_input_tokens":128268,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1132},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WiPnJzYYR7sfkcWRRR","type":"assistant","uuid":"8ff966b0-31cf-4ac0-b4d0-97e00aae6e6f","timestamp":"2026-02-11T15:24:38.516Z"}
{"parentUuid":"8ff966b0-31cf-4ac0-b4d0-97e00aae6e6f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01ULHiH8Uyhnot3etm5Pn5d9","toolUseID":"toolu_01ULHiH8Uyhnot3etm5Pn5d9","timestamp":"2026-02-11T15:24:38.521Z","uuid":"a0d31cc1-4fc2-49b0-89bd-b2bc972a90e3"}
{"parentUuid":"8ff966b0-31cf-4ac0-b4d0-97e00aae6e6f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ULHiH8Uyhnot3etm5Pn5d9","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"4b9ed0aa-309d-4602-86d2-130a221b9ca9","timestamp":"2026-02-11T15:24:38.521Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 4997","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: stripe, captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1710,"oldLines":7,"newStart":1710,"newLines":7,"lines":[" "," Totals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**"," ","-Payment: stripe, captured, 4997","+Payment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"8ff966b0-31cf-4ac0-b4d0-97e00aae6e6f"}
{"parentUuid":"4b9ed0aa-309d-4602-86d2-130a221b9ca9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QtmHNUuX9smcoVVHeQBbGn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0174nWtUgjPxvQGBpqV9iDb5","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","new_string":"**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1578,"cache_read_input_tokens":129400,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1578},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WjGbk3dL5wTPkxJeUL","type":"assistant","uuid":"2f2f9cf3-a143-406d-b71d-d02fc8ae1891","timestamp":"2026-02-11T15:24:42.876Z"}
{"parentUuid":"2f2f9cf3-a143-406d-b71d-d02fc8ae1891","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_0174nWtUgjPxvQGBpqV9iDb5","toolUseID":"toolu_0174nWtUgjPxvQGBpqV9iDb5","timestamp":"2026-02-11T15:24:42.880Z","uuid":"38f24a03-e5dc-45bd-9a14-2465d516946a"}
{"parentUuid":"2f2f9cf3-a143-406d-b71d-d02fc8ae1891","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0174nWtUgjPxvQGBpqV9iDb5","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"3419803b-ec25-4d43-894a-a381f251f3ed","timestamp":"2026-02-11T15:24:42.880Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","newString":"**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1714,"oldLines":11,"newStart":1714,"newLines":12,"lines":[" "," ---"," ","-**Order #1010 - High-value order**","+**Order #1010 - High-value order (PayPal)**"," "," | Field | Value |"," |-------|-------|"," | customer | customer@acme.test (John Doe) |","+| payment_method | paypal |"," | status / financial / fulfillment | paid / paid / unfulfilled |"," | placed_at | 1 day ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"2f2f9cf3-a143-406d-b71d-d02fc8ae1891"}
{"parentUuid":"3419803b-ec25-4d43-894a-a381f251f3ed","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QtmHNUuX9smcoVVHeQBbGn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016KxBWo8e1XteKrbLjyB9GX","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 50498","new_string":"Payment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1578,"cache_read_input_tokens":129400,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1578},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WjGbk3dL5wTPkxJeUL","type":"assistant","uuid":"eae8deeb-1678-4974-9b0e-304e54516880","timestamp":"2026-02-11T15:24:44.150Z"}
{"parentUuid":"eae8deeb-1678-4974-9b0e-304e54516880","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_016KxBWo8e1XteKrbLjyB9GX","toolUseID":"toolu_016KxBWo8e1XteKrbLjyB9GX","timestamp":"2026-02-11T15:24:44.186Z","uuid":"77068b38-0dda-4d45-b9e7-5cb36a3e0df2"}
{"parentUuid":"eae8deeb-1678-4974-9b0e-304e54516880","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016KxBWo8e1XteKrbLjyB9GX","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"3a957f45-f72b-4f00-918f-67956bdce6f3","timestamp":"2026-02-11T15:24:44.185Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 50498","newString":"Payment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: stripe, captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1731,"oldLines":7,"newStart":1731,"newLines":7,"lines":[" "," Totals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**"," ","-Payment: stripe, captured, 50498","+Payment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"eae8deeb-1678-4974-9b0e-304e54516880"}
{"parentUuid":"3a957f45-f72b-4f00-918f-67956bdce6f3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QtmHNUuX9smcoVVHeQBbGn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XycGSVDGhBZLKrXcdHNr6n","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |","new_string":"**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1578,"cache_read_input_tokens":129400,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1578},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WjGbk3dL5wTPkxJeUL","type":"assistant","uuid":"b47252cc-6626-40d6-bae7-e210a44d77ea","timestamp":"2026-02-11T15:24:46.202Z"}
{"parentUuid":"b47252cc-6626-40d6-bae7-e210a44d77ea","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01XycGSVDGhBZLKrXcdHNr6n","toolUseID":"toolu_01XycGSVDGhBZLKrXcdHNr6n","timestamp":"2026-02-11T15:24:46.209Z","uuid":"13244057-19d3-4fe9-b69f-e60d4ee087b5"}
{"parentUuid":"b47252cc-6626-40d6-bae7-e210a44d77ea","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XycGSVDGhBZLKrXcdHNr6n","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"b2aa521a-4906-412f-992a-8ecd5261a0c6","timestamp":"2026-02-11T15:24:46.209Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |","newString":"**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1735,"oldLines":11,"newStart":1735,"newLines":12,"lines":[" "," ---"," ","-**Order #1011 - Single item delivered**","+**Order #1011 - Single item delivered (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | james@example.com (James Taylor) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / paid / fulfilled |"," | placed_at | 25 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"b47252cc-6626-40d6-bae7-e210a44d77ea"}
{"parentUuid":"b2aa521a-4906-412f-992a-8ecd5261a0c6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QtmHNUuX9smcoVVHeQBbGn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KxNjib5xSNiurUin41zmVD","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 3298","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1578,"cache_read_input_tokens":129400,"output_tokens":858,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1578,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WjGbk3dL5wTPkxJeUL","type":"assistant","uuid":"1c5ef082-3758-4e71-b633-e3d63a23fcad","timestamp":"2026-02-11T15:24:47.428Z"}
{"parentUuid":"1c5ef082-3758-4e71-b633-e3d63a23fcad","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01KxNjib5xSNiurUin41zmVD","toolUseID":"toolu_01KxNjib5xSNiurUin41zmVD","timestamp":"2026-02-11T15:24:47.438Z","uuid":"6ad8f232-2c5c-488a-9d91-4d1957141846"}
{"parentUuid":"1c5ef082-3758-4e71-b633-e3d63a23fcad","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KxNjib5xSNiurUin41zmVD","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"dd620ea2-ae42-444f-a854-14b8aa4fb1d9","timestamp":"2026-02-11T15:24:47.437Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 3298","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: stripe, captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1752,"oldLines":7,"newStart":1752,"newLines":7,"lines":[" "," Totals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**"," ","-Payment: stripe, captured, 3298","+Payment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298"," "," Fulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"1c5ef082-3758-4e71-b633-e3d63a23fcad"}
{"parentUuid":"dd620ea2-ae42-444f-a854-14b8aa4fb1d9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KoYggdHVbBzFxwkkEE6gQF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HKsfiKNsKnBCA9yvQJYmP6","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |","new_string":"**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1107,"cache_read_input_tokens":130978,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1107},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WjvBCLLzLptZs8zB3G","type":"assistant","uuid":"3e4ef6dc-5824-4447-970c-992c63a582f6","timestamp":"2026-02-11T15:24:51.934Z"}
{"parentUuid":"3e4ef6dc-5824-4447-970c-992c63a582f6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01HKsfiKNsKnBCA9yvQJYmP6","toolUseID":"toolu_01HKsfiKNsKnBCA9yvQJYmP6","timestamp":"2026-02-11T15:24:51.942Z","uuid":"5973fe01-a0fb-4312-ae41-cebac191d6ef"}
{"parentUuid":"3e4ef6dc-5824-4447-970c-992c63a582f6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HKsfiKNsKnBCA9yvQJYmP6","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"829d854a-6b86-4713-a96a-9faa77ecd7c1","timestamp":"2026-02-11T15:24:51.942Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |","newString":"**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1758,"oldLines":11,"newStart":1758,"newLines":12,"lines":[" "," ---"," ","-**Order #1012 - Multi-quantity order**","+**Order #1012 - Multi-quantity order (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | lisa@example.com (Lisa Anderson) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / paid / unfulfilled |"," | placed_at | 4 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"3e4ef6dc-5824-4447-970c-992c63a582f6"}
{"parentUuid":"829d854a-6b86-4713-a96a-9faa77ecd7c1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KoYggdHVbBzFxwkkEE6gQF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011HdvnnXdkk7DKvAeYqMcpp","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1107,"cache_read_input_tokens":130978,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1107},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WjvBCLLzLptZs8zB3G","type":"assistant","uuid":"6b9cdd45-94d2-44c8-8c67-0f64de1b31b8","timestamp":"2026-02-11T15:24:54.496Z"}
{"parentUuid":"6b9cdd45-94d2-44c8-8c67-0f64de1b31b8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_011HdvnnXdkk7DKvAeYqMcpp","toolUseID":"toolu_011HdvnnXdkk7DKvAeYqMcpp","timestamp":"2026-02-11T15:24:54.505Z","uuid":"5eef52bd-c659-44fe-8de2-884537edb8eb"}
{"parentUuid":"6b9cdd45-94d2-44c8-8c67-0f64de1b31b8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011HdvnnXdkk7DKvAeYqMcpp","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"aa8624c8-e9c9-4d3d-a6b3-f2e37730fc10","timestamp":"2026-02-11T15:24:54.504Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1013 - Multi-item order**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1775,"oldLines":15,"newStart":1775,"newLines":16,"lines":[" "," Totals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**"," ","-Payment: stripe, captured, 8497","+Payment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497"," "," ---"," ","-**Order #1013 - Multi-item order**","+**Order #1013 - Multi-item order (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | robert@example.com (Robert Martinez) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / paid / unfulfilled |"," | placed_at | 1 day ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"6b9cdd45-94d2-44c8-8c67-0f64de1b31b8"}
{"parentUuid":"aa8624c8-e9c9-4d3d-a6b3-f2e37730fc10","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KoYggdHVbBzFxwkkEE6gQF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014kegPpYHiJeqrvoqB6ygmU","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1107,"cache_read_input_tokens":130978,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1107},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WjvBCLLzLptZs8zB3G","type":"assistant","uuid":"febc0ef8-88cd-4aff-9476-14e4c3b9e129","timestamp":"2026-02-11T15:24:57.021Z"}
{"parentUuid":"febc0ef8-88cd-4aff-9476-14e4c3b9e129","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_014kegPpYHiJeqrvoqB6ygmU","toolUseID":"toolu_014kegPpYHiJeqrvoqB6ygmU","timestamp":"2026-02-11T15:24:57.026Z","uuid":"a23dc1d5-5852-4945-94c1-d08afc1ea96d"}
{"parentUuid":"febc0ef8-88cd-4aff-9476-14e4c3b9e129","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014kegPpYHiJeqrvoqB6ygmU","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"88db26d9-33b7-424b-b9fd-d34dc3d44ab9","timestamp":"2026-02-11T15:24:57.025Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: stripe, captured, 8497\n\n---\n\n**Order #1014 - Digital product order**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1797,"oldLines":15,"newStart":1797,"newLines":16,"lines":[" "," Totals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**"," ","-Payment: stripe, captured, 8497","+Payment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497"," "," ---"," ","-**Order #1014 - Digital product order**","+**Order #1014 - Digital product order (Credit Card)**"," "," | Field | Value |"," |-------|-------|"," | customer | anna@example.com (Anna Thomas) |","+| payment_method | credit_card |"," | status / financial / fulfillment | paid / paid / fulfilled |"," | placed_at | 14 days ago |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"febc0ef8-88cd-4aff-9476-14e4c3b9e129"}
{"parentUuid":"88db26d9-33b7-424b-b9fd-d34dc3d44ab9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KoYggdHVbBzFxwkkEE6gQF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015tuAK2FQXwpsbKYQbTS5j4","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at","new_string":"Payment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000\n\nFulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1107,"cache_read_input_tokens":130978,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1107},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WjvBCLLzLptZs8zB3G","type":"assistant","uuid":"625fa055-e9be-4f3e-ab4d-35802c0a7f09","timestamp":"2026-02-11T15:24:59.495Z"}
{"parentUuid":"625fa055-e9be-4f3e-ab4d-35802c0a7f09","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_015tuAK2FQXwpsbKYQbTS5j4","toolUseID":"toolu_015tuAK2FQXwpsbKYQbTS5j4","timestamp":"2026-02-11T15:24:59.500Z","uuid":"089f8841-95fa-46d1-b78b-fa524dc8e382"}
{"parentUuid":"625fa055-e9be-4f3e-ab4d-35802c0a7f09","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015tuAK2FQXwpsbKYQbTS5j4","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"f06ecf2b-a119-456a-9929-fd233046052f","timestamp":"2026-02-11T15:24:59.500Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at","newString":"Payment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000\n\nFulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: stripe, captured, 5000\n\nFulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1818,"oldLines":9,"newStart":1818,"newLines":9,"lines":[" "," Totals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**"," ","-Payment: stripe, captured, 5000","+Payment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000"," ","-Fulfillment: delivered (digital fulfillment), no tracking company, no tracking number, shipped_at = placed_at","+Fulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"625fa055-e9be-4f3e-ab4d-35802c0a7f09"}
{"parentUuid":"f06ecf2b-a119-456a-9929-fd233046052f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KRNBjGGbJUxTcCYZGBbP3B","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HDcEkT4X3uKgDR1fobAzfR","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |","new_string":"**Order #1015 - Order with discount (Bank Transfer, confirmed)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1363,"cache_read_input_tokens":132085,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1363},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wkp2t6UW93wJaRXfUf","type":"assistant","uuid":"2c7f92f5-ffff-4774-a93d-2a7ff00ba6f2","timestamp":"2026-02-11T15:25:05.043Z"}
{"parentUuid":"2c7f92f5-ffff-4774-a93d-2a7ff00ba6f2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01HDcEkT4X3uKgDR1fobAzfR","toolUseID":"toolu_01HDcEkT4X3uKgDR1fobAzfR","timestamp":"2026-02-11T15:25:05.050Z","uuid":"0025acb9-719b-40cd-8b2e-dfa7089dfbd4"}
{"parentUuid":"2c7f92f5-ffff-4774-a93d-2a7ff00ba6f2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HDcEkT4X3uKgDR1fobAzfR","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"7335cf03-34ad-4f0b-921e-234da29bb875","timestamp":"2026-02-11T15:25:05.050Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |","newString":"**Order #1015 - Order with discount (Bank Transfer, confirmed)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000\n\nFulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at\n\n---\n\n**Order #1015 - Order with discount**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1824,"oldLines":11,"newStart":1824,"newLines":12,"lines":[" "," ---"," ","-**Order #1015 - Order with discount**","+**Order #1015 - Order with discount (Bank Transfer, confirmed)**"," "," | Field | Value |"," |-------|-------|"," | customer | customer@acme.test (John Doe) |","+| payment_method | bank_transfer |"," | status / financial / fulfillment | paid / paid / unfulfilled |"," | placed_at | now (today) |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"2c7f92f5-ffff-4774-a93d-2a7ff00ba6f2"}
{"parentUuid":"7335cf03-34ad-4f0b-921e-234da29bb875","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KRNBjGGbJUxTcCYZGBbP3B","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G81J3mkYRviBJshih6Y7bR","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"Payment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.","new_string":"Payment: mock, method \"bank_transfer\", \"mock_test_order1015\", captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n**Test purpose:** Tests a bank transfer order that has been confirmed by the admin (payment status = captured). Contrasts with #1005 which is still pending."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1363,"cache_read_input_tokens":132085,"output_tokens":539,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1363,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2Wkp2t6UW93wJaRXfUf","type":"assistant","uuid":"b13c9c17-897b-49d9-b9fd-58c11c31022d","timestamp":"2026-02-11T15:25:09.031Z"}
{"parentUuid":"b13c9c17-897b-49d9-b9fd-58c11c31022d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01G81J3mkYRviBJshih6Y7bR","toolUseID":"toolu_01G81J3mkYRviBJshih6Y7bR","timestamp":"2026-02-11T15:25:09.037Z","uuid":"daa96e50-4eba-41d7-ad71-8dc360df6129"}
{"parentUuid":"b13c9c17-897b-49d9-b9fd-58c11c31022d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01G81J3mkYRviBJshih6Y7bR","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"c6039337-9059-4406-8ab0-451e76a909d6","timestamp":"2026-02-11T15:25:09.036Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"Payment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.","newString":"Payment: mock, method \"bank_transfer\", \"mock_test_order1015\", captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n**Test purpose:** Tests a bank transfer order that has been confirmed by the admin (payment status = captured). Contrasts with #1005 which is still pending.","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000\n\nFulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at\n\n---\n\n**Order #1015 - Order with discount (Bank Transfer, confirmed)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: stripe, captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1842,"oldLines":10,"newStart":1842,"newLines":12,"lines":[" "," Totals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**"," ","-Payment: stripe, captured, 5447","+Payment: mock, method \"bank_transfer\", \"mock_test_order1015\", captured, 5447"," "," The discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line."," ","+**Test purpose:** Tests a bank transfer order that has been confirmed by the admin (payment status = captured). Contrasts with #1005 which is still pending.","+"," ---"," "," #### Order Summary Table (Acme Fashion)"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"b13c9c17-897b-49d9-b9fd-58c11c31022d"}
{"parentUuid":"c6039337-9059-4406-8ab0-451e76a909d6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FMxmwbE7xEFh7kDvjCGKm7","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the Order Summary Table to include the payment_method column."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":996,"cache_read_input_tokens":133448,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":996},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WmWXwrsZ8Mt3UiYRSi","type":"assistant","uuid":"fd8c9eef-bff4-44a6-ab19-a7096de58a1f","timestamp":"2026-02-11T15:25:11.807Z"}
{"parentUuid":"fd8c9eef-bff4-44a6-ab19-a7096de58a1f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FMxmwbE7xEFh7kDvjCGKm7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CArGC7ZsbmyjPknTMZEFya","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |","new_string":"#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Payment Method | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|---------------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | credit_card | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | credit_card | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | credit_card | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | credit_card | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | bank_transfer | pending | pending | unfulfilled | 3998 | Bank transfer awaiting payment |\n| #1006 | Michael Brown | credit_card | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | paypal | fulfilled | paid | fulfilled | 10496 | Multi-item delivered (PayPal) |\n| #1008 | David Lee | credit_card | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | credit_card | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paypal | paid | paid | unfulfilled | 50498 | High-value order (PayPal) |\n| #1011 | James Taylor | credit_card | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | credit_card | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | credit_card | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | credit_card | paid | paid | fulfilled | 5000 | Digital product (auto-fulfilled) |\n| #1015 | John Doe | bank_transfer | paid | paid | unfulfilled | 5447 | Order with discount (bank transfer, confirmed) |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":996,"cache_read_input_tokens":133448,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":996},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WmWXwrsZ8Mt3UiYRSi","type":"assistant","uuid":"2f68d3e3-bba5-4b36-b3a6-fc1c0825f156","timestamp":"2026-02-11T15:25:23.213Z"}
{"parentUuid":"2f68d3e3-bba5-4b36-b3a6-fc1c0825f156","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01CArGC7ZsbmyjPknTMZEFya","toolUseID":"toolu_01CArGC7ZsbmyjPknTMZEFya","timestamp":"2026-02-11T15:25:23.219Z","uuid":"9e0559fb-1d23-4e0a-be11-dbfd32818e3a"}
{"parentUuid":"2f68d3e3-bba5-4b36-b3a6-fc1c0825f156","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CArGC7ZsbmyjPknTMZEFya","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"8de5c478-7d06-4d8d-83fc-66058b663e7d","timestamp":"2026-02-11T15:25:23.219Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |","newString":"#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Payment Method | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|---------------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | credit_card | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | credit_card | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | credit_card | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | credit_card | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | bank_transfer | pending | pending | unfulfilled | 3998 | Bank transfer awaiting payment |\n| #1006 | Michael Brown | credit_card | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | paypal | fulfilled | paid | fulfilled | 10496 | Multi-item delivered (PayPal) |\n| #1008 | David Lee | credit_card | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | credit_card | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paypal | paid | paid | unfulfilled | 50498 | High-value order (PayPal) |\n| #1011 | James Taylor | credit_card | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | credit_card | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | credit_card | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | credit_card | paid | paid | fulfilled | 5000 | Digital product (auto-fulfilled) |\n| #1015 | John Doe | bank_transfer | paid | paid | unfulfilled | 5447 | Order with discount (bank transfer, confirmed) |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000\n\nFulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at\n\n---\n\n**Order #1015 - Order with discount (Bank Transfer, confirmed)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1015\", captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n**Test purpose:** Tests a bank transfer order that has been confirmed by the admin (payment status = captured). Contrasts with #1005 which is still pending.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |\n| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |\n| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |\n| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |\n| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1852,"oldLines":23,"newStart":1852,"newLines":23,"lines":[" "," #### Order Summary Table (Acme Fashion)"," ","-| Order | Customer | Status | Financial | Fulfillment | Total | Key Test Scenario |","-|-------|----------|--------|-----------|-------------|-------|-------------------|","-| #1001 | John Doe | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |","-| #1002 | John Doe | fulfilled | paid | fulfilled | 8997 | Delivered order |","-| #1003 | Jane Smith | paid | paid | partial | 11997 | Partial fulfillment |","-| #1004 | John Doe | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |","-| #1005 | Jane Smith | pending | pending | unfulfilled | 3998 | Payment pending |","-| #1006 | Michael Brown | paid | paid | unfulfilled | 12498 | Standard paid order |","-| #1007 | Sarah Wilson | fulfilled | paid | fulfilled | 10496 | Multi-item delivered |","-| #1008 | David Lee | paid | partially_refunded | fulfilled | 8997 | Partial refund |","-| #1009 | Emma Garcia | paid | paid | unfulfilled | 4997 | Accessories order |","-| #1010 | John Doe | paid | paid | unfulfilled | 50498 | High-value order |","-| #1011 | James Taylor | paid | paid | fulfilled | 3298 | Single item delivered |","-| #1012 | Lisa Anderson | paid | paid | unfulfilled | 8497 | Multi-quantity order |","-| #1013 | Robert Martinez | paid | paid | unfulfilled | 8497 | Multi-item order |","-| #1014 | Anna Thomas | paid | paid | fulfilled | 5000 | Digital product order |","-| #1015 | John Doe | paid | paid | unfulfilled | 5447 | Order with discount |","+| Order | Customer | Payment Method | Status | Financial | Fulfillment | Total | Key Test Scenario |","+|-------|----------|---------------|--------|-----------|-------------|-------|-------------------|","+| #1001 | John Doe | credit_card | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |","+| #1002 | John Doe | credit_card | fulfilled | paid | fulfilled | 8997 | Delivered order |","+| #1003 | Jane Smith | credit_card | paid | paid | partial | 11997 | Partial fulfillment |","+| #1004 | John Doe | credit_card | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |","+| #1005 | Jane Smith | bank_transfer | pending | pending | unfulfilled | 3998 | Bank transfer awaiting payment |","+| #1006 | Michael Brown | credit_card | paid | paid | unfulfilled | 12498 | Standard paid order |","+| #1007 | Sarah Wilson | paypal | fulfilled | paid | fulfilled | 10496 | Multi-item delivered (PayPal) |","+| #1008 | David Lee | credit_card | paid | partially_refunded | fulfilled | 8997 | Partial refund |","+| #1009 | Emma Garcia | credit_card | paid | paid | unfulfilled | 4997 | Accessories order |","+| #1010 | John Doe | paypal | paid | paid | unfulfilled | 50498 | High-value order (PayPal) |","+| #1011 | James Taylor | credit_card | paid | paid | fulfilled | 3298 | Single item delivered |","+| #1012 | Lisa Anderson | credit_card | paid | paid | unfulfilled | 8497 | Multi-quantity order |","+| #1013 | Robert Martinez | credit_card | paid | paid | unfulfilled | 8497 | Multi-item order |","+| #1014 | Anna Thomas | credit_card | paid | paid | fulfilled | 5000 | Digital product (auto-fulfilled) |","+| #1015 | John Doe | bank_transfer | paid | paid | unfulfilled | 5447 | Order with discount (bank transfer, confirmed) |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"2f68d3e3-bba5-4b36-b3a6-fc1c0825f156"}
{"parentUuid":"8de5c478-7d06-4d8d-83fc-66058b663e7d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CHEynmFwWFXXLZvCTU6VCd","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the Acme Electronics orders and their Refund factory too."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1210,"cache_read_input_tokens":134444,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1210},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WnZSMVk2api7LfDfjC","type":"assistant","uuid":"f5d1b053-af25-4bae-ad3c-d71048365d9b","timestamp":"2026-02-11T15:25:26.525Z"}
{"parentUuid":"f5d1b053-af25-4bae-ad3c-d71048365d9b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CHEynmFwWFXXLZvCTU6VCd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01APyss4hcF6cW1G8UfPpbgQ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |","new_string":"| #5001 | techfan@example.com | credit_card | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | credit_card | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | bank_transfer | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, bank transfer pending |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1210,"cache_read_input_tokens":134444,"output_tokens":437,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1210,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WnZSMVk2api7LfDfjC","type":"assistant","uuid":"c58345fc-9842-4b8d-9ea0-3b0bd4988168","timestamp":"2026-02-11T15:25:31.441Z"}
{"parentUuid":"c58345fc-9842-4b8d-9ea0-3b0bd4988168","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01APyss4hcF6cW1G8UfPpbgQ","toolUseID":"toolu_01APyss4hcF6cW1G8UfPpbgQ","timestamp":"2026-02-11T15:25:31.449Z","uuid":"cf01be18-3445-4de0-add9-ff0800db766b"}
{"parentUuid":"c58345fc-9842-4b8d-9ea0-3b0bd4988168","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01APyss4hcF6cW1G8UfPpbgQ","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"4ac95c39-3927-42f5-9044-6a19e08d6577","timestamp":"2026-02-11T15:25:31.448Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |","newString":"| #5001 | techfan@example.com | credit_card | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | credit_card | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | bank_transfer | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, bank transfer pending |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000\n\nFulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at\n\n---\n\n**Order #1015 - Order with discount (Bank Transfer, confirmed)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1015\", captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n**Test purpose:** Tests a bank transfer order that has been confirmed by the admin (payment status = captured). Contrasts with #1005 which is still pending.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Payment Method | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|---------------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | credit_card | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | credit_card | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | credit_card | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | credit_card | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | bank_transfer | pending | pending | unfulfilled | 3998 | Bank transfer awaiting payment |\n| #1006 | Michael Brown | credit_card | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | paypal | fulfilled | paid | fulfilled | 10496 | Multi-item delivered (PayPal) |\n| #1008 | David Lee | credit_card | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | credit_card | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paypal | paid | paid | unfulfilled | 50498 | High-value order (PayPal) |\n| #1011 | James Taylor | credit_card | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | credit_card | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | credit_card | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | credit_card | paid | paid | fulfilled | 5000 | Digital product (auto-fulfilled) |\n| #1015 | John Doe | bank_transfer | paid | paid | unfulfilled | 5447 | Order with discount (bank transfer, confirmed) |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1876,"oldLines":9,"newStart":1876,"newLines":9,"lines":[" "," | Order | Customer | Status | Financial | Fulfillment | Total | Details |"," |-------|----------|--------|-----------|-------------|-------|---------|","-| #5001 | techfan@example.com | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |","-| #5002 | gadgetlover@example.com | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |","-| #5003 | techfan@example.com | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, payment requires_action |","+| #5001 | techfan@example.com | credit_card | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |","+| #5002 | gadgetlover@example.com | credit_card | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |","+| #5003 | techfan@example.com | bank_transfer | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, bank transfer pending |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"c58345fc-9842-4b8d-9ea0-3b0bd4988168"}
{"parentUuid":"4ac95c39-3927-42f5-9044-6a19e08d6577","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01XstS8fPntPRLgDinbsgK2L","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the RefundFactory to use mock reference IDs."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":485,"cache_read_input_tokens":135654,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":485},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WoASGeNtr3hE7T9iR3","type":"assistant","uuid":"27dc25da-7a64-4ee0-af26-c64ff7c64dfd","timestamp":"2026-02-11T15:25:34.275Z"}
{"parentUuid":"27dc25da-7a64-4ee0-af26-c64ff7c64dfd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01XstS8fPntPRLgDinbsgK2L","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DLuBBTKEgZCTybfspZp12w","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |","new_string":"| provider_refund_id | \"mock_re_\" + Faker 20 random alphanumeric characters |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":485,"cache_read_input_tokens":135654,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":485},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WoASGeNtr3hE7T9iR3","type":"assistant","uuid":"79ee9509-1274-43f4-b380-861426f39cbf","timestamp":"2026-02-11T15:25:36.196Z"}
{"parentUuid":"79ee9509-1274-43f4-b380-861426f39cbf","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01DLuBBTKEgZCTybfspZp12w","toolUseID":"toolu_01DLuBBTKEgZCTybfspZp12w","timestamp":"2026-02-11T15:25:36.200Z","uuid":"681d5ffd-7a6b-4512-80e7-65ff704a1bab"}
{"parentUuid":"79ee9509-1274-43f4-b380-861426f39cbf","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DLuBBTKEgZCTybfspZp12w","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"05c99914-d957-4915-b508-eb8442f6e604","timestamp":"2026-02-11T15:25:36.200Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |","newString":"| provider_refund_id | \"mock_re_\" + Faker 20 random alphanumeric characters |","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000\n\nFulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at\n\n---\n\n**Order #1015 - Order with discount (Bank Transfer, confirmed)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1015\", captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n**Test purpose:** Tests a bank transfer order that has been confirmed by the admin (payment status = captured). Contrasts with #1005 which is still pending.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Payment Method | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|---------------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | credit_card | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | credit_card | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | credit_card | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | credit_card | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | bank_transfer | pending | pending | unfulfilled | 3998 | Bank transfer awaiting payment |\n| #1006 | Michael Brown | credit_card | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | paypal | fulfilled | paid | fulfilled | 10496 | Multi-item delivered (PayPal) |\n| #1008 | David Lee | credit_card | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | credit_card | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paypal | paid | paid | unfulfilled | 50498 | High-value order (PayPal) |\n| #1011 | James Taylor | credit_card | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | credit_card | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | credit_card | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | credit_card | paid | paid | fulfilled | 5000 | Digital product (auto-fulfilled) |\n| #1015 | John Doe | bank_transfer | paid | paid | unfulfilled | 5447 | Order with discount (bank transfer, confirmed) |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | credit_card | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | credit_card | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | bank_transfer | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, bank transfer pending |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":457,"oldLines":7,"newStart":457,"newLines":7,"lines":[" | amount | Random 999-19999 |"," | reason | Faker sentence |"," | status | \"processed\" |","-| provider_refund_id | \"re_\" + Faker 20 random alphanumeric characters |","+| provider_refund_id | \"mock_re_\" + Faker 20 random alphanumeric characters |"," "," **States:**"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"79ee9509-1274-43f4-b380-861426f39cbf"}
{"parentUuid":"05c99914-d957-4915-b508-eb8442f6e604","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MJFsCC1gcEC3gsoE66JAD8","type":"message","role":"assistant","content":[{"type":"text","text":"Also update the refund reference in order #1004."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":251,"cache_read_input_tokens":136139,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":251},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WoWwp3UBgVV8uqGU9v","type":"assistant","uuid":"a6e6410e-dea7-4527-ac5b-80f2911453be","timestamp":"2026-02-11T15:25:38.587Z"}
{"parentUuid":"a6e6410e-dea7-4527-ac5b-80f2911453be","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MJFsCC1gcEC3gsoE66JAD8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UJwjLgANQdK8mmFzTf27C6","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","old_string":"provider_refund_id \"re_test_order1004\"","new_string":"provider_refund_id \"mock_re_test_order1004\""}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":251,"cache_read_input_tokens":136139,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":251},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WoWwp3UBgVV8uqGU9v","type":"assistant","uuid":"68079c2c-cd13-488d-9114-29fbe94ad967","timestamp":"2026-02-11T15:25:39.751Z"}
{"parentUuid":"68079c2c-cd13-488d-9114-29fbe94ad967","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01UJwjLgANQdK8mmFzTf27C6","toolUseID":"toolu_01UJwjLgANQdK8mmFzTf27C6","timestamp":"2026-02-11T15:25:39.758Z","uuid":"b7c04e5d-fd0c-4c10-ba3a-85d56ad9b0e5"}
{"parentUuid":"68079c2c-cd13-488d-9114-29fbe94ad967","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UJwjLgANQdK8mmFzTf27C6","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md has been updated successfully."}]},"uuid":"ea96a732-c518-4c26-a058-6cbb59fd54c5","timestamp":"2026-02-11T15:25:39.758Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/07-SEEDERS-AND-TEST-DATA.md","oldString":"provider_refund_id \"re_test_order1004\"","newString":"provider_refund_id \"mock_re_test_order1004\"","originalFile":"# 07 - Seeders and Test Data Specification\n\n> Complete seeder and factory specification for populating the Shopify-clone with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.\n\n---\n\n## 1. DatabaseSeeder Orchestration\n\nThe main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.\n\n**Execution order:**\n\n| Step | Seeder Class | Depends On | Purpose |\n|------|-------------|------------|---------|\n| 1 | OrganizationSeeder | (none) | Creates billing organizations |\n| 2 | StoreSeeder | Organizations | Creates stores under organizations |\n| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |\n| 4 | UserSeeder | (none) | Creates admin/staff user accounts |\n| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |\n| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |\n| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |\n| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |\n| 9 | CollectionSeeder | Stores | Creates product collections |\n| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |\n| 11 | DiscountSeeder | Stores | Creates discount codes |\n| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |\n| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |\n| 14 | ThemeSeeder | Stores | Creates themes with settings |\n| 15 | PageSeeder | Stores | Creates content pages |\n| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |\n| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |\n| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |\n\n**Dependency diagram (Mermaid):**\n\n```mermaid\ngraph TD\n    A[OrganizationSeeder] --> B[StoreSeeder]\n    B --> C[StoreDomainSeeder]\n    B --> F[StoreSettingsSeeder]\n    B --> G[TaxSettingsSeeder]\n    B --> H[ShippingSeeder]\n    B --> I[CollectionSeeder]\n    D[UserSeeder] --> E[StoreUserSeeder]\n    B --> E\n    B --> J[ProductSeeder]\n    I --> J\n    B --> K[DiscountSeeder]\n    B --> L[CustomerSeeder]\n    J --> M[OrderSeeder]\n    L --> M\n    K --> M\n    B --> N[ThemeSeeder]\n    B --> O[PageSeeder]\n    I --> P[NavigationSeeder]\n    O --> P\n    B --> Q[AnalyticsSeeder]\n    L --> Q\n    B --> R[SearchSettingsSeeder]\n```\n\n**Running seeders:**\n- All seeders: `php artisan db:seed`\n- Single seeder: `php artisan db:seed --class=ProductSeeder`\n- Fresh database with seed data: `php artisan migrate:fresh --seed`\n\n---\n\n## 2. Factory Definitions\n\nEach factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.\n\n---\n\n### 2.1 OrganizationFactory\n\n**Model:** Organization\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| name | Faker company name |\n| billing_email | Faker company email |\n\n**States:** None.\n\n---\n\n### 2.2 StoreFactory\n\n**Model:** Store\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| organization_id | Creates a new Organization via its factory |\n| name | Faker company name + \" Store\" suffix |\n| handle | Faker unique slug (2 words) |\n| status | \"active\" |\n| default_currency | \"EUR\" |\n| default_locale | \"en\" |\n| timezone | \"Europe/Berlin\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `suspended` | status = \"suspended\" |\n\n---\n\n### 2.3 StoreDomainFactory\n\n**Model:** StoreDomain\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| hostname | Faker unique domain name |\n| type | \"storefront\" |\n| is_primary | 1 |\n| tls_mode | \"managed\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `admin` | type = \"admin\" |\n| `api` | type = \"api\" |\n| `secondary` | is_primary = 0 |\n\n---\n\n### 2.4 UserFactory\n\n**Model:** User\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| status | \"active\" |\n| last_login_at | Random date within the last 30 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `disabled` | status = \"disabled\" |\n\n---\n\n### 2.5 ProductFactory\n\n**Model:** Product\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words joined |\n| handle | URL-slug of the title |\n| status | \"active\" |\n| description_html | Faker 2 paragraphs wrapped in `<p>` tags |\n| vendor | Faker company name |\n| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |\n| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n**Relationship hooks:**\n\n| Hook Name | Behavior |\n|-----------|----------|\n| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |\n| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |\n\n---\n\n### 2.6 ProductVariantFactory\n\n**Model:** ProductVariant\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| product_id | Creates a new Product via its factory |\n| sku | Faker unique, pattern: \"SKU-####-???\" (digits and letters) |\n| barcode | Faker EAN-13 |\n| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |\n| compare_at_amount | null |\n| currency | \"EUR\" |\n| weight_g | Random integer 100-5000 |\n| requires_shipping | 1 |\n| is_default | 0 |\n| position | 0 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |\n| `digital` | requires_shipping = 0, weight_g = 0 |\n| `default` | is_default = 1 |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.7 InventoryItemFactory\n\n**Model:** InventoryItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity_on_hand | Random integer 0-100 |\n| quantity_reserved | 0 |\n| policy | \"deny\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `outOfStock` | quantity_on_hand = 0 |\n| `continuePolicy` | policy = \"continue\" |\n| `lowStock` | quantity_on_hand = random 1-3 |\n\n---\n\n### 2.8 CollectionFactory\n\n**Model:** Collection\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 2 random words joined |\n| handle | URL-slug of the title |\n| description_html | Faker sentence wrapped in `<p>` tags |\n| type | \"manual\" |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\" |\n| `archived` | status = \"archived\" |\n| `automated` | type = \"automated\" |\n\n---\n\n### 2.9 CartFactory\n\n**Model:** Cart\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | null |\n| currency | \"EUR\" |\n| cart_version | 1 |\n| status | \"active\" |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `forCustomer` | customer_id = creates a new Customer via its factory |\n| `converted` | status = \"converted\" |\n| `abandoned` | status = \"abandoned\" |\n\n---\n\n### 2.10 CartLineFactory\n\n**Model:** CartLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| cart_id | Creates a new Cart via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| quantity | Random 1-5 |\n| unit_price_amount | Random 999-19999 |\n| line_subtotal_amount | Computed: unit_price_amount * quantity |\n| line_discount_amount | 0 |\n| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |\n\n**States:** None.\n\n---\n\n### 2.11 CheckoutFactory\n\n**Model:** Checkout\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| cart_id | Creates a new Cart via its factory |\n| customer_id | null |\n| status | \"started\" |\n| email | Faker safe email |\n| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code \"DE\", zip |\n| billing_address_json | null |\n| shipping_method_id | null |\n| payment_method | null |\n| discount_code | null |\n| tax_provider_snapshot_json | null |\n| totals_json | null |\n| expires_at | 24 hours from now |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `completed` | status = \"completed\" |\n| `expired` | status = \"expired\", expires_at = 1 hour ago |\n| `withCreditCard` | payment_method = \"credit_card\" |\n| `withPaypal` | payment_method = \"paypal\" |\n| `withBankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.12 OrderFactory\n\n**Model:** Order\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| customer_id | Creates a new Customer via its factory |\n| order_number | \"#\" + Faker unique integer 1001-9999 |\n| payment_method | \"credit_card\" |\n| status | \"paid\" |\n| financial_status | \"paid\" |\n| fulfillment_status | \"unfulfilled\" |\n| currency | \"EUR\" |\n| subtotal_amount | 4998 |\n| discount_amount | 0 |\n| shipping_amount | 499 |\n| tax_amount | 798 |\n| total_amount | 5497 |\n| email | Faker safe email |\n| billing_address_json | JSON address object with German Faker data |\n| shipping_address_json | JSON address object with German Faker data |\n| placed_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", financial_status = \"pending\" |\n| `pendingBankTransfer` | status = \"pending\", financial_status = \"pending\", payment_method = \"bank_transfer\" |\n| `fulfilled` | status = \"fulfilled\", fulfillment_status = \"fulfilled\" |\n| `cancelled` | status = \"cancelled\", financial_status = \"refunded\" |\n| `refunded` | status = \"refunded\", financial_status = \"refunded\" |\n| `partiallyFulfilled` | fulfillment_status = \"partial\" |\n| `creditCard` | payment_method = \"credit_card\" |\n| `paypal` | payment_method = \"paypal\" |\n| `bankTransfer` | payment_method = \"bank_transfer\" |\n\n---\n\n### 2.13 OrderLineFactory\n\n**Model:** OrderLine\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| product_id | Creates a new Product via its factory |\n| variant_id | Creates a new ProductVariant via its factory |\n| title_snapshot | Faker 3 words |\n| sku_snapshot | Faker pattern \"SKU-####-???\" |\n| quantity | Random 1-3 |\n| unit_price_amount | Random 999-19999 |\n| total_amount | Computed: unit_price_amount * quantity |\n| tax_lines_json | Empty JSON array |\n| discount_allocations_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.14 PaymentFactory\n\n**Model:** Payment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| provider | \"mock\" |\n| method | \"credit_card\" |\n| provider_payment_id | \"mock_\" + Faker 20 random alphanumeric characters |\n| status | \"captured\" |\n| amount | Random 999-99999 |\n| currency | \"EUR\" |\n| raw_json_encrypted | null |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\" |\n| `failed` | status = \"failed\" |\n| `refunded` | status = \"refunded\" |\n| `creditCard` | method = \"credit_card\" |\n| `paypal` | method = \"paypal\" |\n| `bankTransfer` | method = \"bank_transfer\" |\n\n---\n\n### 2.15 RefundFactory\n\n**Model:** Refund\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| payment_id | Creates a new Payment via its factory |\n| amount | Random 999-19999 |\n| reason | Faker sentence |\n| status | \"processed\" |\n| provider_refund_id | \"mock_re_\" + Faker 20 random alphanumeric characters |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", provider_refund_id = null |\n| `failed` | status = \"failed\" |\n\n---\n\n### 2.16 FulfillmentFactory\n\n**Model:** Fulfillment\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| order_id | Creates a new Order via its factory |\n| status | \"shipped\" |\n| tracking_company | Random from: DHL, UPS, FedEx, DPD |\n| tracking_number | Faker pattern \"??########\" (2 letters + 8 digits) |\n| tracking_url | \"https://tracking.example.com/\" + tracking_number |\n| shipped_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pending` | status = \"pending\", tracking_number = null, tracking_url = null, shipped_at = null |\n| `delivered` | status = \"delivered\" |\n\n---\n\n### 2.16b FulfillmentLineFactory\n\n**Model:** FulfillmentLine\n\n**Default definition:**\n\n| Field | Rule | Notes |\n|-------|------|-------|\n| fulfillment_id | FK | Parent fulfillment |\n| order_line_id | FK | The order line being fulfilled |\n| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |\n\n**States:** None.\n\n---\n\n### 2.17 CustomerFactory\n\n**Model:** Customer\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| email | Faker unique safe email |\n| password_hash | bcrypt of \"password\" |\n| name | Faker full name |\n| marketing_opt_in | 30% chance of 1, otherwise 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `guest` | password_hash = null |\n| `optedInMarketing` | marketing_opt_in = 1 |\n\n---\n\n### 2.18 CustomerAddressFactory\n\n**Model:** CustomerAddress\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| customer_id | Creates a new Customer via its factory |\n| label | Random from: Home, Work, Other |\n| address_json | JSON object (see Address JSON Structure in Section 6) |\n| is_default | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `default` | is_default = 1 |\n\n---\n\n### 2.19 DiscountFactory\n\n**Model:** Discount\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | \"code\" |\n| code | Faker unique uppercase pattern \"????##\" |\n| value_type | \"percent\" |\n| value_amount | 10 |\n| starts_at | 1 month ago |\n| ends_at | 1 year from now |\n| usage_limit | null |\n| usage_count | 0 |\n| rules_json | Empty JSON object |\n| status | \"active\" |\n\n**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `fixed(amountCents)` | value_type = \"fixed\", value_amount = amountCents |\n| `freeShipping` | value_type = \"free_shipping\", value_amount = 0 |\n| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = \"expired\" |\n| `maxedOut` | usage_limit = 5, usage_count = 5 |\n| `automatic` | type = \"automatic\", code = null |\n| `draft` | status = \"draft\" |\n| `disabled` | status = \"disabled\", starts_at = past date |\n\n---\n\n### 2.20 ShippingZoneFactory\n\n**Model:** ShippingZone\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | Faker country name |\n| countries_json | JSON array: [\"DE\"] |\n| regions_json | Empty JSON array |\n\n**States:** None.\n\n---\n\n### 2.21 ShippingRateFactory\n\n**Model:** ShippingRate\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| zone_id | Creates a new ShippingZone via its factory |\n| name | Random from: Standard, Express, Economy |\n| type | \"flat\" |\n| config_json | JSON object with amount = 499 |\n| is_active | 1 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `inactive` | is_active = 0 |\n| `weightBased` | type = \"weight\", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |\n\n---\n\n### 2.22 ThemeFactory\n\n**Model:** Theme\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| name | \"Default Theme\" |\n| version | \"1.0.0\" |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n\n---\n\n### 2.23 PageFactory\n\n**Model:** Page\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| title | Faker 3 random words |\n| handle | URL-slug of the title |\n| body_html | Faker heading + 3 paragraphs in HTML tags |\n| status | \"published\" |\n| published_at | Current timestamp |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `draft` | status = \"draft\", published_at = null |\n| `archived` | status = \"archived\" |\n\n---\n\n### 2.24 NavigationMenuFactory\n\n**Model:** NavigationMenu\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| handle | Faker unique slug (2 words) |\n| title | Faker 2 words |\n\n**States:** None.\n\n---\n\n### 2.25 NavigationItemFactory\n\n**Model:** NavigationItem\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| menu_id | Creates a new NavigationMenu via its factory |\n| type | \"link\" |\n| label | Faker 2 words |\n| url | \"/\" |\n| resource_id | null |\n| position | 0 |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `page(pageId)` | type = \"page\", url = null, resource_id = pageId |\n| `collection(collectionId)` | type = \"collection\", url = null, resource_id = collectionId |\n| `product(productId)` | type = \"product\", url = null, resource_id = productId |\n\n---\n\n### 2.26 AnalyticsEventFactory\n\n**Model:** AnalyticsEvent\n\n**Default definition:**\n\n| Field | Generation Rule |\n|-------|----------------|\n| store_id | Creates a new Store via its factory |\n| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |\n| session_id | Faker UUID |\n| customer_id | null |\n| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |\n| created_at | Random timestamp within last 7 days |\n\n**States:**\n\n| State Name | Overrides |\n|-----------|-----------|\n| `pageView` | type = \"page_view\" |\n| `productView` | type = \"product_view\", properties_json includes product_id and product_title |\n| `addToCart` | type = \"add_to_cart\", properties_json includes variant_id and quantity |\n\n---\n\n## 3. Demo Seed Scenario (Exact Data)\n\nAll seeders below create deterministic, named records required by E2E tests. References like \"Product 1\" in order lines refer to products by their seed position.\n\n---\n\n### 3.1 OrganizationSeeder\n\nCreates one organization.\n\n| Field | Value |\n|-------|-------|\n| name | Acme Corp |\n| billing_email | billing@acme.test |\n\n---\n\n### 3.2 StoreSeeder\n\nCreates two stores under \"Acme Corp\".\n\n**Store 1: Acme Fashion**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Fashion |\n| handle | acme-fashion |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n**Store 2: Acme Electronics**\n\n| Field | Value |\n|-------|-------|\n| name | Acme Electronics |\n| handle | acme-electronics |\n| status | active |\n| default_currency | EUR |\n| default_locale | en |\n| timezone | Europe/Berlin |\n\n---\n\n### 3.3 StoreDomainSeeder\n\n**Acme Fashion domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-fashion.test | storefront | 1 | managed |\n| admin.acme-fashion.test | admin | 0 | managed |\n\n**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.\n\n**Acme Electronics domains:**\n\n| Hostname | Type | is_primary | tls_mode |\n|----------|------|-----------|----------|\n| acme-electronics.test | storefront | 1 | managed |\n\n---\n\n### 3.4 UserSeeder\n\n| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |\n|-------|------|----------------------------------------------|--------|---------------|\n| admin@acme.test | Admin User | password | active | now |\n| staff@acme.test | Staff User | password | active | 2 days ago |\n| support@acme.test | Support User | password | active | 1 day ago |\n| manager@acme.test | Store Manager | password | active | 1 day ago |\n| admin2@acme.test | Admin Two | password | active | 1 day ago |\n\n---\n\n### 3.5 StoreUserSeeder\n\n| User Email | Store | Role |\n|------------|-------|------|\n| admin@acme.test | Acme Fashion | owner |\n| staff@acme.test | Acme Fashion | staff |\n| support@acme.test | Acme Fashion | support |\n| manager@acme.test | Acme Fashion | admin |\n| admin2@acme.test | Acme Electronics | owner |\n\n---\n\n### 3.6 StoreSettingsSeeder\n\n**Acme Fashion - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Fashion |\n| contact_email | hello@acme-fashion.test |\n| order_number_prefix | # |\n| order_number_start | 1001 |\n\n**Acme Electronics - settings_json contents:**\n\n| Setting Key | Value |\n|-------------|-------|\n| store_name | Acme Electronics |\n| contact_email | hello@acme-electronics.test |\n| order_number_prefix | # |\n| order_number_start | 5001 |\n\n---\n\n### 3.7 TaxSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| mode | manual |\n| provider | none |\n| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |\n| config_json | default_rate_bps: 1900 |\n\nTax rate: 19% (1900 basis points) applied to all zones.\n\n**Acme Electronics:** Same as Acme Fashion.\n\n---\n\n### 3.8 ShippingSeeder\n\n**Acme Fashion - Shipping Zones and Rates:**\n\n**Zone 1: \"Domestic\"**\n- countries_json: [\"DE\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |\n| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |\n\n**Zone 2: \"EU\"**\n- countries_json: [\"AT\", \"FR\", \"IT\", \"ES\", \"NL\", \"BE\", \"PL\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |\n\n**Zone 3: \"Rest of World\"**\n- countries_json: [\"US\", \"GB\", \"CA\", \"AU\"]\n- regions_json: []\n\n| Rate Name | Type | config_json (amount in cents) | is_active |\n|-----------|------|-------------------------------|-----------|\n| International | flat | amount: 1499 (14.99 EUR) | 1 |\n\n**Acme Electronics - One zone:**\n\n**Zone: \"Germany\"**\n- countries_json: [\"DE\"]\n\n| Rate Name | Type | config_json | is_active |\n|-----------|------|-------------|-----------|\n| Standard | flat | amount: 0 (free) | 1 |\n\n---\n\n### 3.9 CollectionSeeder\n\n**Acme Fashion - 4 collections:**\n\n| # | Title | Handle | Type | Status | description_html |\n|---|-------|--------|------|--------|------------------|\n| 1 | New Arrivals | new-arrivals | manual | active | \"Discover the latest additions to our store.\" in `<p>` tag |\n| 2 | T-Shirts | t-shirts | manual | active | \"Premium cotton tees for every occasion.\" in `<p>` tag |\n| 3 | Pants & Jeans | pants-jeans | manual | active | \"Find the perfect fit from our denim and trouser range.\" in `<p>` tag |\n| 4 | Sale | sale | manual | active | \"Great deals on selected items.\" in `<p>` tag |\n\n**Acme Electronics - 2 collections:**\n\n| # | Title | Handle | Type | Status |\n|---|-------|--------|------|--------|\n| 1 | Featured | featured | manual | active |\n| 2 | Accessories | accessories | manual | active |\n\n---\n\n### 3.10 ProductSeeder\n\n#### Acme Fashion - 20 Products\n\nFor each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.\n\n---\n\n**Product 1: \"Classic Cotton T-Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | classic-cotton-t-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\", \"popular\"] |\n| description_html | \"A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, T-Shirts\n\nOptions and values:\n\n| Option Name | Values (in position order) |\n|-------------|---------------------------|\n| Size | S, M, L, XL |\n| Color | White, Black, Navy |\n\nVariants (12 total - all size x color combinations):\n- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)\n- First variant (S/White) is marked is_default = 1\n- Position increments 0 through 11\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 2499 (24.99 EUR) |\n| compare_at_amount | null |\n| currency | EUR |\n| weight_g | 200 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 15, policy = \"deny\"\n\n---\n\n**Product 2: \"Premium Slim Fit Jeans\"**\n\n| Field | Value |\n|-------|-------|\n| handle | premium-slim-fit-jeans |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"new\", \"sale\"] |\n| description_html | \"Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.\" in `<p>` tag |\n| published_at | now |\n\nCollections: New Arrivals, Pants & Jeans, Sale\n\nOptions and values:\n\n| Option Name | Values |\n|-------------|--------|\n| Size | 28, 30, 32, 34, 36 |\n| Color | Blue, Black |\n\nVariants (10 total): First variant (28/Blue) is default.\n\n| Per-variant field | Value |\n|-------------------|-------|\n| price_amount | 7999 (79.99 EUR) |\n| compare_at_amount | 9999 (99.99 EUR - on sale) |\n| weight_g | 800 |\n| requires_shipping | 1 |\n\nInventory per variant: quantity_on_hand = 8, policy = \"deny\"\n\n---\n\n**Product 3: \"Organic Hoodie\"**\n\n| Field | Value |\n|-------|-------|\n| handle | organic-hoodie |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Hoodies |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Made from 100% organic cotton. Warm, soft, and sustainably produced.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)\n\n---\n\n**Product 4: \"Leather Belt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | leather-belt |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Genuine leather belt with brushed metal buckle. A wardrobe essential.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S/M, L/XL), Color (Brown, Black)\n\nVariants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)\n\n---\n\n**Product 5: \"Running Sneakers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | running-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"trending\"] |\n| description_html | \"Lightweight running sneakers with responsive cushioning and breathable mesh upper.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)\n\nVariants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK\n\n---\n\n**Product 6: \"Graphic Print Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | graphic-print-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"new\"] |\n| description_html | \"Bold graphic print on soft cotton. Express yourself with this statement piece.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)\n\n---\n\n**Product 7: \"V-Neck Linen Tee\"**\n\n| Field | Value |\n|-------|-------|\n| handle | v-neck-linen-tee |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"popular\"] |\n| description_html | \"Lightweight linen blend v-neck. Perfect for warm summer days.\" |\n| published_at | now |\n\nCollections: T-Shirts\n\nOptions: Size (S, M, L), Color (Beige, Olive, Sky Blue)\n\nVariants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)\n\n---\n\n**Product 8: \"Striped Polo Shirt\"**\n\n| Field | Value |\n|-------|-------|\n| handle | striped-polo-shirt |\n| status | active |\n| vendor | Acme Basics |\n| product_type | T-Shirts |\n| tags | [\"sale\"] |\n| description_html | \"Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.\" |\n| published_at | now |\n\nCollections: T-Shirts, Sale\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)\n\n---\n\n**Product 9: \"Cargo Pants\"**\n\n| Field | Value |\n|-------|-------|\n| handle | cargo-pants |\n| status | active |\n| vendor | Acme Workwear |\n| product_type | Pants |\n| tags | [\"popular\"] |\n| description_html | \"Utility cargo pants with multiple pockets. Durable cotton twill construction.\" |\n| published_at | now |\n\nCollections: Pants & Jeans\n\nOptions: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)\n\nVariants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)\n\n---\n\n**Product 10: \"Chino Shorts\"**\n\n| Field | Value |\n|-------|-------|\n| handle | chino-shorts |\n| status | active |\n| vendor | Acme Basics |\n| product_type | Pants |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, New Arrivals\n\nOptions: Size (30, 32, 34, 36), Color (Navy, Sand)\n\nVariants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)\n\n---\n\n**Product 11: \"Wide Leg Trousers\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wide-leg-trousers |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Pants |\n| tags | [\"sale\"] |\n| description_html | \"Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.\" |\n| published_at | now |\n\nCollections: Pants & Jeans, Sale\n\nOptions: Size (S, M, L)\n\nVariants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)\n\n---\n\n**Product 12: \"Wool Scarf\"**\n\n| Field | Value |\n|-------|-------|\n| handle | wool-scarf |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"popular\"] |\n| description_html | \"Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Grey, Burgundy, Navy)\n\nVariants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)\n\n---\n\n**Product 13: \"Canvas Tote Bag\"**\n\n| Field | Value |\n|-------|-------|\n| handle | canvas-tote-bag |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"trending\"] |\n| description_html | \"Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Color (Natural, Black)\n\nVariants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)\n\n---\n\n**Product 14: \"Bucket Hat\"**\n\n| Field | Value |\n|-------|-------|\n| handle | bucket-hat |\n| status | active |\n| vendor | Acme Accessories |\n| product_type | Accessories |\n| tags | [\"new\", \"trending\"] |\n| description_html | \"Lightweight bucket hat for sun protection. Packable design, washed cotton twill.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S/M, L/XL), Color (Beige, Black, Olive)\n\nVariants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)\n\n---\n\n**Product 15: \"Unreleased Winter Jacket\" -- DRAFT**\n\n| Field | Value |\n|-------|-------|\n| handle | unreleased-winter-jacket |\n| status | **draft** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [\"limited\"] |\n| description_html | \"Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.\" |\n| published_at | **null** |\n\nCollections: (none - draft not assigned)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)\n\n**Test purpose:** Verifies draft products do not appear on the storefront.\n\n---\n\n**Product 16: \"Discontinued Raincoat\" -- ARCHIVED**\n\n| Field | Value |\n|-------|-------|\n| handle | discontinued-raincoat |\n| status | **archived** |\n| vendor | Acme Outerwear |\n| product_type | Jackets |\n| tags | [] |\n| description_html | \"Lightweight waterproof raincoat. This product has been discontinued.\" |\n| published_at | 6 months ago |\n\nCollections: (none)\n\nOptions: Size (M, L)\n\nVariants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)\n\n**Test purpose:** Verifies archived products do not appear on the storefront.\n\n---\n\n**Product 17: \"Limited Edition Sneakers\" -- SOLD OUT (deny policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | limited-edition-sneakers |\n| status | active |\n| vendor | Acme Sport |\n| product_type | Shoes |\n| tags | [\"limited\"] |\n| description_html | \"Limited edition collaboration sneakers. Once they are gone, they are gone.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (EU 40, EU 42, EU 44)\n\nVariants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = \"deny\"**\n\n**Test purpose:** Tests \"sold out\" display and add-to-cart prevention.\n\n---\n\n**Product 18: \"Backorder Denim Jacket\" -- OUT OF STOCK (continue policy)**\n\n| Field | Value |\n|-------|-------|\n| handle | backorder-denim-jacket |\n| status | active |\n| vendor | Acme Denim |\n| product_type | Jackets |\n| tags | [\"popular\"] |\n| description_html | \"Classic denim jacket. Currently on backorder - ships within 2-3 weeks.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Size (S, M, L, XL)\n\nVariants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = \"continue\"**\n\n**Test purpose:** Tests that items with \"continue\" inventory policy can be added to cart despite zero stock.\n\n---\n\n**Product 19: \"Gift Card\" -- DIGITAL (no shipping)**\n\n| Field | Value |\n|-------|-------|\n| handle | gift-card |\n| status | active |\n| vendor | Acme Fashion |\n| product_type | Gift Cards |\n| tags | [\"popular\"] |\n| description_html | \"Digital gift card delivered via email. The perfect gift when you are not sure what to choose.\" |\n| published_at | now |\n\nCollections: (none)\n\nOptions: Amount (25 EUR, 50 EUR, 100 EUR)\n\nVariants (3 total - each a different denomination):\n\n| SKU | Price (cents) | Weight | requires_shipping | Inventory |\n|-----|---------------|--------|-------------------|-----------|\n| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |\n| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |\n\n**Test purpose:** Tests digital product flow (no shipping step in checkout).\n\n---\n\n**Product 20: \"Cashmere Overcoat\" -- EXPENSIVE**\n\n| Field | Value |\n|-------|-------|\n| handle | cashmere-overcoat |\n| status | active |\n| vendor | Acme Premium |\n| product_type | Jackets |\n| tags | [\"limited\", \"new\"] |\n| description_html | \"Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.\" |\n| published_at | now |\n\nCollections: New Arrivals\n\nOptions: Size (S, M, L), Color (Camel, Charcoal)\n\nVariants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)\n\n**Test purpose:** Tests high-value price display (499.99 EUR).\n\n---\n\n#### Product Summary Table (Acme Fashion)\n\n| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |\n|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|\n| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |\n| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |\n| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |\n| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |\n| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |\n| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |\n| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |\n| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |\n| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |\n| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |\n| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |\n| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |\n| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |\n| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |\n| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |\n| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |\n| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |\n| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |\n| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |\n| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |\n\n**Total variants across Acme Fashion:** 107\n\n---\n\n#### Acme Electronics - 5 Products (minimal seed for tenant isolation)\n\nThese products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.\n\n| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |\n|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|\n| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |\n| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |\n| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |\n| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |\n| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |\n\nAll active, published now, tags vary. Inventory policy = \"deny\" for all.\n\n**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.\n\n---\n\n### 3.11 DiscountSeeder\n\n**Acme Fashion - 5 discount codes:**\n\n| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |\n|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|\n| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |\n| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |\n| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |\n| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |\n| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |\n\n**Behavioral expectations:**\n- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).\n- FLAT5: Applies flat 5.00 EUR discount. Never used.\n- FREESHIP: Removes shipping cost. Used once.\n- EXPIRED20: Expired - checkout must reject this code with \"discount expired\" error.\n- MAXED: Usage limit of 5 reached - checkout must reject this code with \"discount limit reached\" error.\n\n---\n\n### 3.12 CustomerSeeder\n\n**Acme Fashion - 10 customers:**\n\n| # | Email | Name | Password (plaintext) | marketing_opt_in |\n|---|-------|------|----------------------|------------------|\n| 1 | customer@acme.test | John Doe | password | 1 |\n| 2 | jane@example.com | Jane Smith | password | 0 |\n| 3 | michael@example.com | Michael Brown | password | 1 |\n| 4 | sarah@example.com | Sarah Wilson | password | 0 |\n| 5 | david@example.com | David Lee | password | 1 |\n| 6 | emma@example.com | Emma Garcia | password | 0 |\n| 7 | james@example.com | James Taylor | password | 0 |\n| 8 | lisa@example.com | Lisa Anderson | password | 1 |\n| 9 | robert@example.com | Robert Martinez | password | 0 |\n| 10 | anna@example.com | Anna Thomas | password | 1 |\n\n**Customer 1 (customer@acme.test) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | John Doe, Hauptstrasse 1, 10115 Berlin, DE, phone: +49 30 12345678 |\n| Work | 0 | John Doe, Acme Corp, Friedrichstrasse 100, 3rd Floor, 10117 Berlin, DE, phone: +49 30 87654321 |\n\n**Customer 2 (jane@example.com) addresses:**\n\n| Label | is_default | Address Details |\n|-------|-----------|-----------------|\n| Home | 1 | Jane Smith, Schillerstrasse 45, 80336 Munich, Bavaria (BY), DE |\n\n**Customers 3-10:** Each gets one default address with Faker-generated German addresses.\n\n**Acme Electronics - 2 customers (tenant isolation testing):**\n\n| # | Email | Name | Password |\n|---|-------|------|----------|\n| 1 | techfan@example.com | Tech Fan | password |\n| 2 | gadgetlover@example.com | Gadget Lover | password |\n\nEach has one default address with Faker-generated data.\n\n---\n\n### 3.13 OrderSeeder\n\n**Acme Fashion - 15 orders:**\n\nAll orders use the customer's default shipping address (unless noted). Order numbers follow the \"#NNNN\" format starting from #1001.\n\n---\n\n**Order #1001 - Awaiting fulfillment (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 2 days ago |\n\nLines:\n\n| Product | Variant (option values) | Qty | Unit Price | Line Total |\n|---------|------------------------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | S / White | 2 | 2499 | 4998 |\n\nTotals: subtotal 4998, discount 0, shipping 499, tax 798, total **5497**\n\nPayment: mock, method \"credit_card\", provider_payment_id \"mock_test_order1001\", status \"captured\", amount 5497\n\nFulfillments: None.\n\n---\n\n**Order #1002 - Fully delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 10 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Organic Hoodie | M | 1 | 5999 | 5999 |\n| Classic Cotton T-Shirt | L / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1002\", captured, 8997\n\nFulfillment: status \"delivered\", tracking company \"DHL\", tracking number \"DHL1234567890\", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.\n\n---\n\n**Order #1003 - Partially fulfilled (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / partial |\n| placed_at | 5 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Premium Slim Fit Jeans | 32 / Blue | 1 | 7999 | 7999 |\n| Leather Belt | L/XL / Brown | 1 | 3499 | 3499 |\n\nTotals: subtotal 11498, discount 0, shipping 499, tax 1836, total **11997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1003\", captured, 11997\n\nFulfillment: status \"shipped\", DHL, \"DHL9876543210\", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.\n\n---\n\n**Order #1004 - Cancelled with full refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | credit_card |\n| status / financial / fulfillment | cancelled / refunded / unfulfilled |\n| placed_at | 15 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Classic Cotton T-Shirt | M / Navy | 1 | 2499 | 2499 |\n\nTotals: subtotal 2499, discount 0, shipping 499, tax 399, total **2998**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998\n\nRefund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"\n\n---\n\n**Order #1005 - Bank transfer awaiting payment**\n\n| Field | Value |\n|-------|-------|\n| customer | jane@example.com (Jane Smith) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | pending / pending / unfulfilled |\n| placed_at | 2 hours ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Leather Belt | S/M / Black | 1 | 3499 | 3499 |\n\nTotals: subtotal 3499, discount 0, shipping 499, tax 559, total **3998**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1005\", status \"pending\", amount 3998\n\n**Test purpose:** Tests the bank transfer flow - admin \"Confirm Payment\" button, fulfillment guard (cannot fulfill until payment confirmed), and auto-cancel job scenario.\n\n---\n\n**Order #1006 - Standard paid order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | michael@example.com (Michael Brown) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Running Sneakers | EU 42 / Black | 1 | 11999 | 11999 |\n\nTotals: subtotal 11999, discount 0, shipping 499, tax 1916, total **12498**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1006\", captured, 12498\n\n---\n\n**Order #1007 - Multi-item delivered (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | sarah@example.com (Sarah Wilson) |\n| payment_method | paypal |\n| status / financial / fulfillment | fulfilled / paid / fulfilled |\n| placed_at | 20 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| V-Neck Linen Tee | M / Beige | 2 | 3499 | 6998 |\n| Wool Scarf | Grey | 1 | 2999 | 2999 |\n\nTotals: subtotal 9997, discount 0, shipping 499, tax 1596, total **10496**\n\nPayment: mock, method \"paypal\", \"mock_test_order1007\", captured, 10496\n\nFulfillment: delivered, DHL, \"DHL1112223334\", shipped 18 days ago. All lines fulfilled.\n\n---\n\n**Order #1008 - Partial refund (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | david@example.com (David Lee) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / partially_refunded / fulfilled |\n| placed_at | 12 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cargo Pants | 32 / Khaki | 1 | 5499 | 5499 |\n| Graphic Print Tee | L | 1 | 2999 | 2999 |\n\nTotals: subtotal 8498, discount 0, shipping 499, tax 1357, total **8997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1008\", captured, 8997\n\nFulfillment: delivered, UPS, \"UPS5556667778\", shipped 10 days ago\n\nRefund (partial - for Graphic Print Tee only): amount 2999, reason \"Item returned\", status \"processed\", provider_refund_id \"mock_re_test_order1008\"\n\n---\n\n**Order #1009 - Accessories order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | emma@example.com (Emma Garcia) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 3 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Canvas Tote Bag | Natural | 1 | 1999 | 1999 |\n| Bucket Hat | S/M / Black | 1 | 2499 | 2499 |\n\nTotals: subtotal 4498, discount 0, shipping 499, tax 718, total **4997**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1009\", captured, 4997\n\n---\n\n**Order #1010 - High-value order (PayPal)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | paypal |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Cashmere Overcoat | M / Camel | 1 | 49999 | 49999 |\n\nTotals: subtotal 49999, discount 0, shipping 499, tax 7983, total **50498**\n\nPayment: mock, method \"paypal\", \"mock_test_order1010\", captured, 50498\n\n---\n\n**Order #1011 - Single item delivered (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | james@example.com (James Taylor) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 25 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Striped Polo Shirt | XL | 1 | 2799 | 2799 |\n\nTotals: subtotal 2799, discount 0, shipping 499, tax 447, total **3298**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1011\", captured, 3298\n\nFulfillment: delivered, FedEx, \"FX9998887776\", shipped 23 days ago\n\n---\n\n**Order #1012 - Multi-quantity order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | lisa@example.com (Lisa Anderson) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 4 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Chino Shorts | 34 / Navy | 2 | 3999 | 7998 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1012\", captured, 8497\n\n---\n\n**Order #1013 - Multi-item order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | robert@example.com (Robert Martinez) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | 1 day ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Wide Leg Trousers | M | 1 | 4999 | 4999 |\n| Wool Scarf | Burgundy | 1 | 2999 | 2999 |\n\nTotals: subtotal 7998, discount 0, shipping 499, tax 1277, total **8497**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1013\", captured, 8497\n\n---\n\n**Order #1014 - Digital product order (Credit Card)**\n\n| Field | Value |\n|-------|-------|\n| customer | anna@example.com (Anna Thomas) |\n| payment_method | credit_card |\n| status / financial / fulfillment | paid / paid / fulfilled |\n| placed_at | 14 days ago |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total |\n|---------|---------|-----|-----------|-----------|\n| Gift Card | 50 EUR | 1 | 5000 | 5000 |\n\nTotals: subtotal 5000, discount 0, shipping **0** (digital product), tax 798, total **5000**\n\nPayment: mock, method \"credit_card\", \"mock_test_order1014\", captured, 5000\n\nFulfillment: delivered (auto-fulfilled digital product), no tracking company, no tracking number, shipped_at = placed_at, delivered_at = placed_at\n\n---\n\n**Order #1015 - Order with discount (Bank Transfer, confirmed)**\n\n| Field | Value |\n|-------|-------|\n| customer | customer@acme.test (John Doe) |\n| payment_method | bank_transfer |\n| status / financial / fulfillment | paid / paid / unfulfilled |\n| placed_at | now (today) |\n\nLines:\n\n| Product | Variant | Qty | Unit Price | Line Total | Discount Allocation |\n|---------|---------|-----|-----------|-----------|-------------------|\n| Classic Cotton T-Shirt | M / White | 1 | 2499 | 2499 | WELCOME10 discount, amount: 250 |\n| Graphic Print Tee | M | 1 | 2999 | 2999 | WELCOME10 discount, amount: 300 |\n\nTotals: subtotal 5498, discount **550**, shipping 499, tax 790, total **5447**\n\nPayment: mock, method \"bank_transfer\", \"mock_test_order1015\", captured, 5447\n\nThe discount_allocations_json on each order line references the WELCOME10 discount's database ID and the allocated discount amount for that specific line.\n\n**Test purpose:** Tests a bank transfer order that has been confirmed by the admin (payment status = captured). Contrasts with #1005 which is still pending.\n\n---\n\n#### Order Summary Table (Acme Fashion)\n\n| Order | Customer | Payment Method | Status | Financial | Fulfillment | Total | Key Test Scenario |\n|-------|----------|---------------|--------|-----------|-------------|-------|-------------------|\n| #1001 | John Doe | credit_card | paid | paid | unfulfilled | 5497 | Awaiting fulfillment |\n| #1002 | John Doe | credit_card | fulfilled | paid | fulfilled | 8997 | Delivered order |\n| #1003 | Jane Smith | credit_card | paid | paid | partial | 11997 | Partial fulfillment |\n| #1004 | John Doe | credit_card | cancelled | refunded | unfulfilled | 2998 | Full cancellation + refund |\n| #1005 | Jane Smith | bank_transfer | pending | pending | unfulfilled | 3998 | Bank transfer awaiting payment |\n| #1006 | Michael Brown | credit_card | paid | paid | unfulfilled | 12498 | Standard paid order |\n| #1007 | Sarah Wilson | paypal | fulfilled | paid | fulfilled | 10496 | Multi-item delivered (PayPal) |\n| #1008 | David Lee | credit_card | paid | partially_refunded | fulfilled | 8997 | Partial refund |\n| #1009 | Emma Garcia | credit_card | paid | paid | unfulfilled | 4997 | Accessories order |\n| #1010 | John Doe | paypal | paid | paid | unfulfilled | 50498 | High-value order (PayPal) |\n| #1011 | James Taylor | credit_card | paid | paid | fulfilled | 3298 | Single item delivered |\n| #1012 | Lisa Anderson | credit_card | paid | paid | unfulfilled | 8497 | Multi-quantity order |\n| #1013 | Robert Martinez | credit_card | paid | paid | unfulfilled | 8497 | Multi-item order |\n| #1014 | Anna Thomas | credit_card | paid | paid | fulfilled | 5000 | Digital product (auto-fulfilled) |\n| #1015 | John Doe | bank_transfer | paid | paid | unfulfilled | 5447 | Order with discount (bank transfer, confirmed) |\n\n---\n\n**Acme Electronics - 3 orders (minimal, for tenant isolation):**\n\n| Order | Customer | Status | Financial | Fulfillment | Total | Details |\n|-------|----------|--------|-----------|-------------|-------|---------|\n| #5001 | techfan@example.com | credit_card | paid | paid | fulfilled | 121298 | Pro Laptop 512GB (119999) + USB-C Cable (1299), shipping 0 |\n| #5002 | gadgetlover@example.com | credit_card | paid | paid | unfulfilled | 14999 | Wireless Headphones Black (14999), shipping 0 |\n| #5003 | techfan@example.com | bank_transfer | pending | pending | unfulfilled | 4999 | Monitor Stand (4999), shipping 0, bank transfer pending |\n\n---\n\n### 3.14 ThemeSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n| published_at | now |\n\n**Theme settings (theme_settings row):**\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #1a1a2e |\n| secondary_color | #e94560 |\n| font_family | Inter, sans-serif |\n| hero_heading | Welcome to Acme Fashion |\n| hero_subheading | Discover our curated collection of modern essentials |\n| hero_cta_text | Shop New Arrivals |\n| hero_cta_link | /collections/new-arrivals |\n| featured_collection_handles | [\"new-arrivals\", \"t-shirts\", \"sale\"] |\n| footer_text | 2025 Acme Fashion. All rights reserved. |\n| show_announcement_bar | true |\n| announcement_text | Free shipping on orders over 50 EUR - Use code FREESHIP |\n| products_per_page | 12 |\n| show_vendor | true |\n| show_quantity_selector | true |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| name | Default Theme |\n| version | 1.0.0 |\n| status | published |\n\nTheme settings:\n\n| Setting Key | Value |\n|-------------|-------|\n| primary_color | #0f172a |\n| secondary_color | #3b82f6 |\n| font_family | Inter, sans-serif |\n| hero_heading | Acme Electronics |\n| hero_subheading | Premium tech for professionals |\n| hero_cta_text | Shop Featured |\n| hero_cta_link | /collections/featured |\n| featured_collection_handles | [\"featured\"] |\n| footer_text | 2025 Acme Electronics. All rights reserved. |\n\n---\n\n### 3.15 PageSeeder\n\n**Acme Fashion - 5 content pages:**\n\nAll pages: status = \"published\", published_at = 3 months ago.\n\n**Page 1: About Us**\n- handle: about\n- body_html content: Three sections with h2 headings - \"Our Story\" (mission and philosophy), \"Our Values\" (ethical sourcing, sustainability, fair labor), \"Our Team\" (Berlin-based designers). Multiple paragraphs of realistic store copy.\n\n**Page 2: FAQ**\n- handle: faq\n- body_html content: h2 \"Frequently Asked Questions\" followed by four Q&A pairs using h3/p structure:\n  1. Shipping timeframes (Germany 2-4 days standard, 1-2 express; EU 5-7 days)\n  2. Return policy (30 days, unworn, original packaging)\n  3. International shipping (EU + US, UK, CA, AU)\n  4. Order tracking (email with tracking number after shipment)\n\n**Page 3: Shipping & Returns**\n- handle: shipping-returns\n- body_html content: Shipping rates table (Germany standard 4.99/express 9.99, EU 8.99, International 14.99) using h3 and ul/li elements, plus returns policy paragraph (30 days, customer pays return shipping unless defective).\n\n**Page 4: Privacy Policy**\n- handle: privacy-policy\n- body_html content: Standard privacy policy with sections: Information We Collect, How We Use Your Information, Cookies, Contact (privacy@acme-fashion.test). Uses h2/h3/p structure.\n\n**Page 5: Terms of Service**\n- handle: terms\n- body_html content: Standard ToS with sections: Orders and Payments (EUR, tax-inclusive), Product Descriptions (color variance disclaimer), Limitation of Liability, Governing Law (Federal Republic of Germany). Uses h2/h3/p structure.\n\n---\n\n### 3.16 NavigationSeeder\n\n**Acme Fashion - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | New Arrivals | collection | resource_id: ID of \"New Arrivals\" collection |\n| 2 | T-Shirts | collection | resource_id: ID of \"T-Shirts\" collection |\n| 3 | Pants & Jeans | collection | resource_id: ID of \"Pants & Jeans\" collection |\n| 4 | Sale | collection | resource_id: ID of \"Sale\" collection |\n\n**Acme Fashion - Footer Menu (handle: footer-menu, title: \"Footer Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | About Us | page | resource_id: ID of \"About Us\" page |\n| 1 | FAQ | page | resource_id: ID of \"FAQ\" page |\n| 2 | Shipping & Returns | page | resource_id: ID of \"Shipping & Returns\" page |\n| 3 | Privacy Policy | page | resource_id: ID of \"Privacy Policy\" page |\n| 4 | Terms of Service | page | resource_id: ID of \"Terms of Service\" page |\n\n**Acme Electronics - Main Menu (handle: main-menu, title: \"Main Menu\"):**\n\n| Position | Label | Type | Target |\n|----------|-------|------|--------|\n| 0 | Home | link | URL: / |\n| 1 | Featured | collection | resource_id: ID of \"Featured\" collection |\n| 2 | Accessories | collection | resource_id: ID of \"Accessories\" collection |\n\n---\n\n### 3.17 AnalyticsSeeder\n\n**Acme Fashion - analytics_daily (30 days of daily data):**\n\nGenerate one row per day for the past 30 days (today minus 30 through today). Revenue trends upward to simulate growth.\n\n**Generation algorithm (pseudo-code):**\n\n```\nFOR i FROM 30 DOWN TO 0:\n    dayFactor = 1 + (30 - i) * 0.03       // ~3% daily growth factor\n    visits = ROUND(RANDOM(50, 100) * dayFactor)\n    addToCart = ROUND(visits * RANDOM(18, 25) / 100)\n    checkoutStarted = ROUND(addToCart * RANDOM(40, 55) / 100)\n    orders = MAX(2, ROUND(checkoutStarted * RANDOM(35, 55) / 100))\n    aov = RANDOM(4000, 9000)               // average order value in cents\n    revenue = orders * aov\n\n    INSERT analytics_daily:\n        store_id = Acme Fashion ID\n        date = TODAY minus i days (ISO format YYYY-MM-DD)\n        visits_count = visits\n        add_to_cart_count = addToCart\n        checkout_started_count = checkoutStarted\n        orders_count = orders\n        revenue_amount = revenue\n        aov_amount = aov\n```\n\n**Expected ranges for sanity checking:**\n\n| Day Range | Visits | Orders | Revenue (cents) |\n|-----------|--------|--------|-----------------|\n| Days -30 to -21 (oldest) | 50-100 | 2-4 | 7000-30000 |\n| Days -20 to -11 (middle) | 70-140 | 3-6 | 15000-45000 |\n| Days -10 to -1 (recent) | 100-190 | 4-8 | 30000-65000 |\n| Day 0 (today, partial) | 80-110 | 2-4 | 15000-30000 |\n\n---\n\n**Acme Fashion - analytics_events (200+ events, last 7 days):**\n\n**Event type distribution:**\n\n| Type | Percentage | Count (of ~220 total) |\n|------|-----------|----------------------|\n| page_view | 40% | ~88 |\n| product_view | 25% | ~55 |\n| add_to_cart | 15% | ~33 |\n| checkout_started | 10% | ~22 |\n| checkout_completed | 5% | ~11 |\n| search | 5% | ~11 |\n\n**Session grouping:** Create ~30-40 unique session IDs. Each session should have 5-10 events. This simulates realistic user journeys.\n\n**Customer association:** 30% of events should have a customer_id pointing to a random Acme Fashion customer. The remaining 70% represent anonymous visitors (customer_id = null).\n\n**Properties by event type:**\n\n| Event Type | properties_json keys |\n|------------|---------------------|\n| page_view | url (e.g. \"/\", \"/collections/t-shirts\"), referrer (optional, e.g. \"https://www.google.com\") |\n| product_view | product_id, product_title, url |\n| add_to_cart | product_id, variant_id, quantity, price_amount |\n| checkout_started | cart_id, item_count, cart_total |\n| checkout_completed | order_id, order_number, total_amount |\n| search | query (e.g. \"cotton t-shirt\", \"jeans\", \"gift card\"), results_count |\n\n**created_at:** Random timestamps spread across the last 7 days, with more events on recent days.\n\n---\n\n### 3.18 SearchSettingsSeeder\n\n**Acme Fashion:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"tee\", \"t-shirt\", \"tshirt\"], [\"pants\", \"trousers\", \"jeans\"], [\"sneakers\", \"trainers\", \"shoes\"], [\"hoodie\", \"sweatshirt\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\", \"of\", \"is\"] |\n\n**Acme Electronics:**\n\n| Field | Value |\n|-------|-------|\n| synonyms_json | [[\"laptop\", \"notebook\", \"computer\"], [\"headphones\", \"earphones\", \"earbuds\"], [\"cable\", \"cord\", \"wire\"]] |\n| stop_words_json | [\"the\", \"a\", \"an\", \"and\", \"or\"] |\n\n---\n\n## 4. Collection-Product Assignments Summary\n\n**Acme Fashion:**\n\n| Collection | Product Numbers Assigned |\n|------------|------------------------|\n| New Arrivals | 1, 2, 3, 5, 10, 14, 20 |\n| T-Shirts | 1, 6, 7, 8 |\n| Pants & Jeans | 2, 9, 10, 11 |\n| Sale | 2, 8, 11 |\n\nThese are inserted into the `collection_products` pivot table with position values matching the order listed above (position 0, 1, 2, ...).\n\n**Acme Electronics:**\n\n| Collection | Products Assigned |\n|------------|------------------|\n| Featured | E1, E2, E4 |\n| Accessories | E3, E5 |\n\n---\n\n## 5. Test Credentials Summary\n\n```\n============================================\n  ADMIN PANEL CREDENTIALS\n============================================\n\nAdmin Login:\n  Email:    admin@acme.test\n  Password: password\n  Store:    Acme Fashion (acme-fashion.test)\n  Role:     owner\n\nStaff Login:\n  Email:    staff@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     staff\n\nSupport Login:\n  Email:    support@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     support\n\nManager Login:\n  Email:    manager@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Role:     admin\n\nAdmin Two Login:\n  Email:    admin2@acme.test\n  Password: password\n  Store:    Acme Electronics (acme-electronics.test)\n  Role:     owner\n\n============================================\n  STOREFRONT CUSTOMER CREDENTIALS\n============================================\n\nPrimary Test Customer:\n  Email:    customer@acme.test\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default), Work\n\nSecondary Test Customer:\n  Email:    jane@example.com\n  Password: password\n  Store:    Acme Fashion\n  Addresses: Home (default)\n\n============================================\n  DISCOUNT CODES (Acme Fashion)\n============================================\n\nWELCOME10  - 10% off, min order 20.00 EUR (active, usable)\nFLAT5      - 5.00 EUR off (active, usable)\nFREESHIP   - Free shipping (active, usable)\nEXPIRED20  - 20% off (expired, must be rejected)\nMAXED      - 10% off (usage limit reached, must be rejected)\n\n============================================\n  SPECIAL PRODUCTS FOR TESTING\n============================================\n\nDraft:      \"Unreleased Winter Jacket\" (not visible on storefront)\nArchived:   \"Discontinued Raincoat\" (not visible on storefront)\nSold Out:   \"Limited Edition Sneakers\" (deny policy, qty 0)\nBackorder:  \"Backorder Denim Jacket\" (continue policy, qty 0)\nDigital:    \"Gift Card\" (no shipping required)\nExpensive:  \"Cashmere Overcoat\" (499.99 EUR)\n```\n\n---\n\n## 6. Implementation Notes\n\n### Idempotency\n\nEach seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.\n\n### Performance\n\nThe full seed creates approximately 250 records. With SQLite this should complete in under 5 seconds. Wrap each seeder's work in a single database transaction for speed.\n\n### Timestamps\n\nAll relative dates (placed_at, shipped_at, created_at) use offsets from \"now\" (e.g. \"2 days ago\", \"10 days ago\") so seed data always looks recent regardless of when it is run.\n\n### Variant-Option Wiring\n\nAfter creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:\n\n1. Create the product\n2. Create product_options (e.g. \"Size\", \"Color\") with position 0, 1, ...\n3. Create product_option_values for each option (e.g. \"S\", \"M\", \"L\" for Size)\n4. Create product_variants with SKU, price, etc.\n5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant \"S/White\" maps to Size=\"S\" and Color=\"White\" option value IDs)\n\n### Inventory Items\n\nEvery variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.\n\n### Order Line Snapshots\n\nOrder lines store `title_snapshot` and `sku_snapshot` which are copies of the product title and variant SKU at the time of purchase. The seeder should populate these from the actual product/variant records created earlier in the seed process.\n\n### Address JSON Structure\n\nAll `shipping_address_json`, `billing_address_json` (on orders), and `address_json` (on customer_addresses) follow this structure:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| first_name | string | Customer first name |\n| last_name | string | Customer last name |\n| company | string | Company name (empty string if none) |\n| address1 | string | Street address line 1 |\n| address2 | string | Street address line 2 (empty string if none) |\n| city | string | City name |\n| province | string | Province/state name (empty string for Germany) |\n| province_code | string | Province/state code (empty string for Germany) |\n| country | string | Full country name (e.g. \"Germany\") |\n| country_code | string | ISO 3166-1 alpha-2 code (e.g. \"DE\") |\n| zip | string | Postal code |\n| phone | string | Phone number (empty string if none) |\n\n### Discount Allocation on Order Lines\n\nFor orders that use a discount code (e.g. Order #1015 with WELCOME10), each order line's `discount_allocations_json` contains an array of allocation objects. Each allocation references the discount's database ID and the cent amount allocated to that specific line. The total of all allocations across all lines equals the order's `discount_amount`.\n","structuredPatch":[{"oldStart":1594,"oldLines":7,"newStart":1594,"newLines":7,"lines":[" "," Payment: mock, method \"credit_card\", \"mock_test_order1004\", status \"refunded\", amount 2998"," ","-Refund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"re_test_order1004\"","+Refund: amount 2998, reason \"Customer requested cancellation\", status \"processed\", provider_refund_id \"mock_re_test_order1004\""," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"68079c2c-cd13-488d-9114-29fbe94ad967"}
{"parentUuid":"ea96a732-c518-4c26-a058-6cbb59fd54c5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FwtbG2n9Uzkss77Jqw9ScC","type":"message","role":"assistant","content":[{"type":"text","text":"Task #6 done. Now Task #7 (08-PLAYWRIGHT-E2E-PLAN.md)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":230,"cache_read_input_tokens":136390,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":230},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WonAbejApVw2Mq1EdV","type":"assistant","uuid":"485b8e25-6d57-45b8-ae2f-761a7cc77e47","timestamp":"2026-02-11T15:25:42.826Z"}
{"parentUuid":"485b8e25-6d57-45b8-ae2f-761a7cc77e47","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FwtbG2n9Uzkss77Jqw9ScC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DazxJvuxKEkpJprPmxNJAM","name":"TaskUpdate","input":{"taskId":"6","status":"completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":230,"cache_read_input_tokens":136390,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":230},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WonAbejApVw2Mq1EdV","type":"assistant","uuid":"0a7c1318-04dd-4945-bfd8-637939aa4d0f","timestamp":"2026-02-11T15:25:43.382Z"}
{"parentUuid":"0a7c1318-04dd-4945-bfd8-637939aa4d0f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DazxJvuxKEkpJprPmxNJAM","type":"tool_result","content":"Updated task #6 status"}]},"uuid":"dc009b57-82e1-42b9-bbf0-e7122b407e54","timestamp":"2026-02-11T15:25:43.384Z","toolUseResult":{"success":true,"taskId":"6","updatedFields":["status"],"statusChange":{"from":"in_progress","to":"completed"}},"sourceToolAssistantUUID":"0a7c1318-04dd-4945-bfd8-637939aa4d0f"}
{"parentUuid":"dc009b57-82e1-42b9-bbf0-e7122b407e54","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FwtbG2n9Uzkss77Jqw9ScC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SxUG618FLTbxUHX29F981b","name":"TaskUpdate","input":{"taskId":"7","status":"in_progress"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":230,"cache_read_input_tokens":136390,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":230},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WonAbejApVw2Mq1EdV","type":"assistant","uuid":"b5139705-cd35-4e86-a00e-d4856d34a3f3","timestamp":"2026-02-11T15:25:43.753Z"}
{"parentUuid":"b5139705-cd35-4e86-a00e-d4856d34a3f3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SxUG618FLTbxUHX29F981b","type":"tool_result","content":"Updated task #7 status"}]},"uuid":"f4d5e1ae-d983-49ce-b069-4fae76cd3aea","timestamp":"2026-02-11T15:25:43.754Z","toolUseResult":{"success":true,"taskId":"7","updatedFields":["status"],"statusChange":{"from":"pending","to":"in_progress"}},"sourceToolAssistantUUID":"b5139705-cd35-4e86-a00e-d4856d34a3f3"}
{"parentUuid":"f4d5e1ae-d983-49ce-b069-4fae76cd3aea","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QphffjMpsaoTUuJhxhsuRj","type":"message","role":"assistant","content":[{"type":"text","text":"For 08-PLAYWRIGHT-E2E-PLAN.md, I need to:\n1. Update Suite 9 test count (add payment method tests)\n2. Update the checkout step detail table (payment method selection)\n3. Update Test 9.1 happy path to include payment method selection\n4. Add new tests for PayPal, bank transfer, card decline (magic number)\n5. Add admin order management tests for confirm payment and fulfillment guard\n6. Update Suite 4 tests for fulfillment actions"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":225,"cache_read_input_tokens":136620,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":225},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wp5FWMiaubq81jp7PZ","type":"assistant","uuid":"6050a875-7062-4985-8e4a-7c8f600517c4","timestamp":"2026-02-11T15:25:49.586Z"}
{"type":"file-history-snapshot","messageId":"6d03d427-dc18-4d67-8699-1d879c10481a","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v1","version":1,"backupTime":"2026-02-11T15:14:23.948Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v1","version":1,"backupTime":"2026-02-11T15:16:45.432Z"},"specs/03-ADMIN-UI.md":{"backupFileName":"a791bee027d52553@v1","version":1,"backupTime":"2026-02-11T15:21:51.319Z"},"specs/04-STOREFRONT-UI.md":{"backupFileName":"401411061d8a6ac1@v1","version":1,"backupTime":"2026-02-11T15:22:26.455Z"},"specs/07-SEEDERS-AND-TEST-DATA.md":{"backupFileName":"c11f09e3e5d3a009@v1","version":1,"backupTime":"2026-02-11T15:23:20.806Z"},"specs/08-PLAYWRIGHT-E2E-PLAN.md":{"backupFileName":"2744918423fe477c@v1","version":1,"backupTime":"2026-02-11T15:25:53.175Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"6050a875-7062-4985-8e4a-7c8f600517c4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QphffjMpsaoTUuJhxhsuRj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011A8PFWjDJoy5y3Z7YgW2Ps","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |","new_string":"| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":225,"cache_read_input_tokens":136620,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":225},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wp5FWMiaubq81jp7PZ","type":"assistant","uuid":"6d03d427-dc18-4d67-8699-1d879c10481a","timestamp":"2026-02-11T15:25:53.170Z"}
{"parentUuid":"6d03d427-dc18-4d67-8699-1d879c10481a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_011A8PFWjDJoy5y3Z7YgW2Ps","toolUseID":"toolu_011A8PFWjDJoy5y3Z7YgW2Ps","timestamp":"2026-02-11T15:25:53.180Z","uuid":"40b49916-fac5-4d27-bf50-a9b2806f8a48"}
{"parentUuid":"6d03d427-dc18-4d67-8699-1d879c10481a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011A8PFWjDJoy5y3Z7YgW2Ps","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"b3ef78a1-7922-4a6f-981e-dd9de0790696","timestamp":"2026-02-11T15:25:53.179Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |","newString":"| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **134** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]\n    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]\n\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout happy path\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify total is 29.98 (24.99 + 4.99 shipping)\n20. Click \"Pay now\" (test/mock payment flow)\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":11,"oldLines":7,"newStart":11,"newLines":7,"lines":[" | 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |"," | 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |"," | 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |","-| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 | Order listing, filtering, detail view, fulfillment, refund | Admin user, Orders (#1001), Customer |","+| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |"," | 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |"," | 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |"," | 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"6d03d427-dc18-4d67-8699-1d879c10481a"}
{"parentUuid":"b3ef78a1-7922-4a6f-981e-dd9de0790696","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QphffjMpsaoTUuJhxhsuRj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BXpiw5rzYw26aeWSg8RqxF","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |","new_string":"| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":225,"cache_read_input_tokens":136620,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":225},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wp5FWMiaubq81jp7PZ","type":"assistant","uuid":"ce7d3c2b-7f26-48c8-aac6-e22192ff16a4","timestamp":"2026-02-11T15:25:56.077Z"}
{"parentUuid":"ce7d3c2b-7f26-48c8-aac6-e22192ff16a4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01BXpiw5rzYw26aeWSg8RqxF","toolUseID":"toolu_01BXpiw5rzYw26aeWSg8RqxF","timestamp":"2026-02-11T15:25:56.083Z","uuid":"94434328-4147-4653-b519-b64d0b006850"}
{"parentUuid":"ce7d3c2b-7f26-48c8-aac6-e22192ff16a4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BXpiw5rzYw26aeWSg8RqxF","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"80c2ec81-87f2-46dd-a245-f50cf87edf13","timestamp":"2026-02-11T15:25:56.083Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |","newString":"| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **134** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]\n    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]\n\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout happy path\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify total is 29.98 (24.99 + 4.99 shipping)\n20. Click \"Pay now\" (test/mock payment flow)\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":16,"oldLines":7,"newStart":16,"newLines":7,"lines":[" | 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |"," | 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |"," | 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |","-| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 | Full checkout: contact, address, shipping, payment, confirmation | Products, Shipping zones, Discounts |","+| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |"," | 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |"," | 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |"," | 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"ce7d3c2b-7f26-48c8-aac6-e22192ff16a4"}
{"parentUuid":"80c2ec81-87f2-46dd-a245-f50cf87edf13","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FU3mxAAJsna9TQm2yBLT2L","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the total test count."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":747,"cache_read_input_tokens":136845,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":747},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wpz7Dx2Pj7U2FMpEGx","type":"assistant","uuid":"e0d31adf-8979-4b37-b57d-9b999f23a69d","timestamp":"2026-02-11T15:25:58.423Z"}
{"parentUuid":"e0d31adf-8979-4b37-b57d-9b999f23a69d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FU3mxAAJsna9TQm2yBLT2L","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01D54nvqghotUtHXMywdMYsZ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"| **Total** | **18 files** | **134** | | |","new_string":"| **Total** | **18 files** | **143** | | |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":747,"cache_read_input_tokens":136845,"output_tokens":179,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":747,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2Wpz7Dx2Pj7U2FMpEGx","type":"assistant","uuid":"78bb98fb-439d-4499-973e-eda57cc8eb38","timestamp":"2026-02-11T15:25:59.941Z"}
{"parentUuid":"78bb98fb-439d-4499-973e-eda57cc8eb38","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01D54nvqghotUtHXMywdMYsZ","toolUseID":"toolu_01D54nvqghotUtHXMywdMYsZ","timestamp":"2026-02-11T15:25:59.951Z","uuid":"cfcd8d0f-fb0e-43ff-9a95-738ada7100ef"}
{"parentUuid":"78bb98fb-439d-4499-973e-eda57cc8eb38","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01D54nvqghotUtHXMywdMYsZ","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"a89ee320-6ccf-4513-b18d-479f086f7f8d","timestamp":"2026-02-11T15:25:59.950Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"| **Total** | **18 files** | **134** | | |","newString":"| **Total** | **18 files** | **143** | | |","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **134** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]\n    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]\n\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout happy path\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify total is 29.98 (24.99 + 4.99 shipping)\n20. Click \"Pay now\" (test/mock payment flow)\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":26,"oldLines":7,"newStart":26,"newLines":7,"lines":[" | 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |"," | 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |"," | 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |","-| **Total** | **18 files** | **134** | | |","+| **Total** | **18 files** | **143** | | |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"78bb98fb-439d-4499-973e-eda57cc8eb38"}
{"parentUuid":"a89ee320-6ccf-4513-b18d-479f086f7f8d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G5YtwiNjnAfbhp3Zgdhezd","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the checkout step detail and flow diagram."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":227,"cache_read_input_tokens":137592,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":227},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WqGGpS3LcLUBJJE6uq","type":"assistant","uuid":"e8db93c2-917f-4f26-af4c-62ac24c87d3a","timestamp":"2026-02-11T15:26:02.799Z"}
{"parentUuid":"e8db93c2-917f-4f26-af4c-62ac24c87d3a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G5YtwiNjnAfbhp3Zgdhezd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RiMeE69odyTkomXHkDnbzz","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]\n    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]\n\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |","new_string":"```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":227,"cache_read_input_tokens":137592,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":227},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WqGGpS3LcLUBJJE6uq","type":"assistant","uuid":"80ce9a90-5c04-4562-89d1-bf816f0f2728","timestamp":"2026-02-11T15:26:18.401Z"}
{"parentUuid":"80ce9a90-5c04-4562-89d1-bf816f0f2728","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01RiMeE69odyTkomXHkDnbzz","toolUseID":"toolu_01RiMeE69odyTkomXHkDnbzz","timestamp":"2026-02-11T15:26:18.406Z","uuid":"7916d1b8-a806-4aa7-9b6f-c250a45d1970"}
{"parentUuid":"80ce9a90-5c04-4562-89d1-bf816f0f2728","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RiMeE69odyTkomXHkDnbzz","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"74f4a824-6497-4cec-98c2-e85259157320","timestamp":"2026-02-11T15:26:18.406Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]\n    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]\n\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |","newString":"```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]\n    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]\n\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout happy path\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify total is 29.98 (24.99 + 4.99 shipping)\n20. Click \"Pay now\" (test/mock payment flow)\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":1628,"oldLines":9,"newStart":1628,"newLines":12,"lines":["     Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]","     Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]","     Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]","-    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment]","-    Step4 -->|click 'Pay now'| Confirmation[Order Confirmation]","+    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]","+    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]","+    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation","+    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]"," ","+    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]","     Step1 -->|empty email| Step1Error[Validation error on email]","     Step2 -->|missing fields| Step2Error[Validation errors on address fields]","     Step2 -->|invalid postal code| Step2PError[Validation error on postal code]"]},{"oldStart":1648,"oldLines":8,"newStart":1651,"newLines":8,"lines":[" | 1. Contact | Contact section | email | Email summary shown |"," | 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |"," | 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |","-| 4. Payment | Payment section | Payment method (Stripe test mode) | Redirect to confirmation |","-| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals |","+| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |","+| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |"," "," ### Totals Calculation Reference"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"80ce9a90-5c04-4562-89d1-bf816f0f2728"}
{"parentUuid":"74f4a824-6497-4cec-98c2-e85259157320","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011tTgezxwdMbXSEbnQTHBFG","type":"message","role":"assistant","content":[{"type":"text","text":"Now update Test 9.1 to include payment method selection, and add new tests 9.9-9.13."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1181,"cache_read_input_tokens":137819,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1181},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WrdSZvrkEx6pBc5dJ6","type":"assistant","uuid":"4a4805d5-12dc-4fec-9d74-da93db0be95c","timestamp":"2026-02-11T15:26:21.255Z"}
{"parentUuid":"4a4805d5-12dc-4fec-9d74-da93db0be95c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_011tTgezxwdMbXSEbnQTHBFG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DfhBZMCzghvhKAHqQQGox2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"### Test 9.1: Completes full checkout happy path\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify total is 29.98 (24.99 + 4.99 shipping)\n20. Click \"Pay now\" (test/mock payment flow)","new_string":"### Test 9.1: Completes full checkout with credit card\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify \"Credit Card\" is pre-selected as payment method\n20. Enter card number: `4242 4242 4242 4242`\n21. Enter cardholder name: `Test Buyer`\n22. Enter expiry: `12/28`\n23. Enter CVC: `123`\n24. Verify total is 29.98 (24.99 + 4.99 shipping)\n25. Click \"Pay now\""}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1181,"cache_read_input_tokens":137819,"output_tokens":784,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1181,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WrdSZvrkEx6pBc5dJ6","type":"assistant","uuid":"f98ff6e3-b559-4b24-97f3-2f1c01078773","timestamp":"2026-02-11T15:26:30.468Z"}
{"parentUuid":"f98ff6e3-b559-4b24-97f3-2f1c01078773","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01DfhBZMCzghvhKAHqQQGox2","toolUseID":"toolu_01DfhBZMCzghvhKAHqQQGox2","timestamp":"2026-02-11T15:26:30.474Z","uuid":"bb5c27ab-9121-47a3-8c42-6d898881392e"}
{"parentUuid":"f98ff6e3-b559-4b24-97f3-2f1c01078773","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DfhBZMCzghvhKAHqQQGox2","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"66d323de-9ca6-4197-bf5e-891d48034e4c","timestamp":"2026-02-11T15:26:30.474Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"### Test 9.1: Completes full checkout happy path\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify total is 29.98 (24.99 + 4.99 shipping)\n20. Click \"Pay now\" (test/mock payment flow)","newString":"### Test 9.1: Completes full checkout with credit card\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify \"Credit Card\" is pre-selected as payment method\n20. Enter card number: `4242 4242 4242 4242`\n21. Enter cardholder name: `Test Buyer`\n22. Enter expiry: `12/28`\n23. Enter CVC: `123`\n24. Verify total is 29.98 (24.99 + 4.99 shipping)\n25. Click \"Pay now\"","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout happy path\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify total is 29.98 (24.99 + 4.99 shipping)\n20. Click \"Pay now\" (test/mock payment flow)\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":1676,"oldLines":7,"newStart":1676,"newLines":7,"lines":[" "," ---"," ","-### Test 9.1: Completes full checkout happy path","+### Test 9.1: Completes full checkout with credit card"," "," **Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR"," "]},{"oldStart":1699,"oldLines":8,"newStart":1699,"newLines":13,"lines":[" 16. Verify \"Standard Shipping\" and \"4.99\" are displayed"," 17. Click \"Standard Shipping\" to select it"," 18. Click \"Continue\"","-19. Verify total is 29.98 (24.99 + 4.99 shipping)","-20. Click \"Pay now\" (test/mock payment flow)","+19. Verify \"Credit Card\" is pre-selected as payment method","+20. Enter card number: `4242 4242 4242 4242`","+21. Enter cardholder name: `Test Buyer`","+22. Enter expiry: `12/28`","+23. Enter CVC: `123`","+24. Verify total is 29.98 (24.99 + 4.99 shipping)","+25. Click \"Pay now\""," "," **Expected Results:**"," - Confirmation page displays \"Thank you\""]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"f98ff6e3-b559-4b24-97f3-2f1c01078773"}
{"parentUuid":"66d323de-9ca6-4197-bf5e-891d48034e4c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SLmfVg7oQv2bkKiDFobgNj","type":"message","role":"assistant","content":[{"type":"text","text":"Now add new tests after 9.8. Let me read where Test 9.8 ends."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":832,"cache_read_input_tokens":139000,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":832},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WsWhHb7r8JCernwdeX","type":"assistant","uuid":"44b130b0-5008-4066-a7ab-34090dad9fa1","timestamp":"2026-02-11T15:26:33.503Z"}
{"parentUuid":"44b130b0-5008-4066-a7ab-34090dad9fa1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01SLmfVg7oQv2bkKiDFobgNj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01APewVfJNE5RYPpWTbpcusF","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account","new_string":"---\n\n### Test 9.9: Completes checkout with PayPal\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"PayPal\" radio button to select PayPal as payment method\n9. Click \"Pay with PayPal\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays \"PayPal\" in payment method section\n- No JavaScript errors\n\n---\n\n### Test 9.10: Completes checkout with bank transfer\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"Bank Transfer\" radio button to select Bank Transfer as payment method\n9. Click \"Place order\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays bank transfer instructions (IBAN, BIC, reference)\n- Confirmation page displays the order number as reference\n- No JavaScript errors\n\n---\n\n### Test 9.11: Shows error for declined credit card (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Verify \"Credit Card\" is selected\n9. Enter card number: `4000 0000 0000 0002` (magic decline card)\n10. Enter cardholder name: `Test Buyer`\n11. Enter expiry: `12/28`\n12. Enter CVC: `123`\n13. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"declined\"\n- Customer remains on the checkout page (NOT redirected to confirmation)\n- No JavaScript errors\n\n---\n\n### Test 9.12: Shows error for insufficient funds (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Enter card number: `4000 0000 0000 9995` (magic insufficient funds card)\n9. Enter cardholder name: `Test Buyer`\n10. Enter expiry: `12/28`\n11. Enter CVC: `123`\n12. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"insufficient\"\n- Customer remains on the checkout page\n- No JavaScript errors\n\n---\n\n### Test 9.13: Switches between payment method forms\n\n**Preconditions:** Product #1, reached Step 4 (payment) in checkout\n\n**Steps:**\n1. Complete checkout steps 1-3 (contact, address, shipping)\n2. Verify \"Credit Card\" is selected and card form fields are visible\n3. Click \"PayPal\" radio button\n4. Verify card form fields are hidden\n5. Verify \"Pay with PayPal\" button is visible\n6. Click \"Bank Transfer\" radio button\n7. Verify \"Place order\" button is visible\n8. Verify bank transfer information text is displayed\n\n**Expected Results:**\n- Payment form dynamically updates based on selected payment method\n- No JavaScript errors\n\n**Total: 13 tests**\n\n---\n\n## Suite 10: Customer Account"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":832,"cache_read_input_tokens":139000,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":832},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WsWhHb7r8JCernwdeX","type":"assistant","uuid":"a8237502-0c9e-4892-8cba-083872f8ec80","timestamp":"2026-02-11T15:26:47.642Z"}
{"parentUuid":"a8237502-0c9e-4892-8cba-083872f8ec80","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01APewVfJNE5RYPpWTbpcusF","toolUseID":"toolu_01APewVfJNE5RYPpWTbpcusF","timestamp":"2026-02-11T15:26:47.648Z","uuid":"96301dad-e829-4364-8f71-f86c98bbe6bf"}
{"parentUuid":"a8237502-0c9e-4892-8cba-083872f8ec80","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01APewVfJNE5RYPpWTbpcusF","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"425b449c-806e-4cb7-8bbc-9e4b32f3334e","timestamp":"2026-02-11T15:26:47.648Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account","newString":"---\n\n### Test 9.9: Completes checkout with PayPal\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"PayPal\" radio button to select PayPal as payment method\n9. Click \"Pay with PayPal\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays \"PayPal\" in payment method section\n- No JavaScript errors\n\n---\n\n### Test 9.10: Completes checkout with bank transfer\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"Bank Transfer\" radio button to select Bank Transfer as payment method\n9. Click \"Place order\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays bank transfer instructions (IBAN, BIC, reference)\n- Confirmation page displays the order number as reference\n- No JavaScript errors\n\n---\n\n### Test 9.11: Shows error for declined credit card (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Verify \"Credit Card\" is selected\n9. Enter card number: `4000 0000 0000 0002` (magic decline card)\n10. Enter cardholder name: `Test Buyer`\n11. Enter expiry: `12/28`\n12. Enter CVC: `123`\n13. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"declined\"\n- Customer remains on the checkout page (NOT redirected to confirmation)\n- No JavaScript errors\n\n---\n\n### Test 9.12: Shows error for insufficient funds (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Enter card number: `4000 0000 0000 9995` (magic insufficient funds card)\n9. Enter cardholder name: `Test Buyer`\n10. Enter expiry: `12/28`\n11. Enter CVC: `123`\n12. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"insufficient\"\n- Customer remains on the checkout page\n- No JavaScript errors\n\n---\n\n### Test 9.13: Switches between payment method forms\n\n**Preconditions:** Product #1, reached Step 4 (payment) in checkout\n\n**Steps:**\n1. Complete checkout steps 1-3 (contact, address, shipping)\n2. Verify \"Credit Card\" is selected and card form fields are visible\n3. Click \"PayPal\" radio button\n4. Verify card form fields are hidden\n5. Verify \"Pay with PayPal\" button is visible\n6. Click \"Bank Transfer\" radio button\n7. Verify \"Place order\" button is visible\n8. Verify bank transfer information text is displayed\n\n**Expected Results:**\n- Payment form dynamically updates based on selected payment method\n- No JavaScript errors\n\n**Total: 13 tests**\n\n---\n\n## Suite 10: Customer Account","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout with credit card\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify \"Credit Card\" is pre-selected as payment method\n20. Enter card number: `4242 4242 4242 4242`\n21. Enter cardholder name: `Test Buyer`\n22. Enter expiry: `12/28`\n23. Enter CVC: `123`\n24. Verify total is 29.98 (24.99 + 4.99 shipping)\n25. Click \"Pay now\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":1882,"oldLines":10,"newStart":1882,"newLines":126,"lines":[" - \"Checkout\" button is disabled or not shown"," - No JavaScript errors"," ","-**Total: 8 tests**","+---"," ","+### Test 9.9: Completes checkout with PayPal","+","+**Preconditions:** Product #1, Domestic shipping zone","+","+**Steps:**","+1. Navigate to `/products/classic-cotton-t-shirt`","+2. Click \"M\" to select size M, click \"Black\" to select color Black","+3. Click \"Add to cart\"","+4. Navigate to `/cart`, click \"Checkout\"","+5. Enter email: `test@example.com`, click \"Continue\"","+6. Fill shipping address (DE), click \"Continue\"","+7. Select \"Standard Shipping\", click \"Continue\"","+8. Click \"PayPal\" radio button to select PayPal as payment method","+9. Click \"Pay with PayPal\"","+","+**Expected Results:**","+- Confirmation page displays \"Thank you\"","+- Confirmation page displays \"PayPal\" in payment method section","+- No JavaScript errors","+"," ---"," ","+### Test 9.10: Completes checkout with bank transfer","+","+**Preconditions:** Product #1, Domestic shipping zone","+","+**Steps:**","+1. Navigate to `/products/classic-cotton-t-shirt`","+2. Click \"M\" to select size M, click \"Black\" to select color Black","+3. Click \"Add to cart\"","+4. Navigate to `/cart`, click \"Checkout\"","+5. Enter email: `test@example.com`, click \"Continue\"","+6. Fill shipping address (DE), click \"Continue\"","+7. Select \"Standard Shipping\", click \"Continue\"","+8. Click \"Bank Transfer\" radio button to select Bank Transfer as payment method","+9. Click \"Place order\"","+","+**Expected Results:**","+- Confirmation page displays \"Thank you\"","+- Confirmation page displays bank transfer instructions (IBAN, BIC, reference)","+- Confirmation page displays the order number as reference","+- No JavaScript errors","+","+---","+","+### Test 9.11: Shows error for declined credit card (magic number)","+","+**Preconditions:** Product #1, Domestic shipping zone","+","+**Steps:**","+1. Navigate to `/products/classic-cotton-t-shirt`","+2. Click \"M\" to select size M, click \"Black\" to select color Black","+3. Click \"Add to cart\"","+4. Navigate to `/cart`, click \"Checkout\"","+5. Enter email: `test@example.com`, click \"Continue\"","+6. Fill shipping address (DE), click \"Continue\"","+7. Select \"Standard Shipping\", click \"Continue\"","+8. Verify \"Credit Card\" is selected","+9. Enter card number: `4000 0000 0000 0002` (magic decline card)","+10. Enter cardholder name: `Test Buyer`","+11. Enter expiry: `12/28`","+12. Enter CVC: `123`","+13. Click \"Pay now\"","+","+**Expected Results:**","+- Page displays an error message containing \"declined\"","+- Customer remains on the checkout page (NOT redirected to confirmation)","+- No JavaScript errors","+","+---","+","+### Test 9.12: Shows error for insufficient funds (magic number)","+","+**Preconditions:** Product #1, Domestic shipping zone","+","+**Steps:**","+1. Navigate to `/products/classic-cotton-t-shirt`","+2. Click \"M\" to select size M, click \"Black\" to select color Black","+3. Click \"Add to cart\"","+4. Navigate to `/cart`, click \"Checkout\"","+5. Enter email: `test@example.com`, click \"Continue\"","+6. Fill shipping address (DE), click \"Continue\"","+7. Select \"Standard Shipping\", click \"Continue\"","+8. Enter card number: `4000 0000 0000 9995` (magic insufficient funds card)","+9. Enter cardholder name: `Test Buyer`","+10. Enter expiry: `12/28`","+11. Enter CVC: `123`","+12. Click \"Pay now\"","+","+**Expected Results:**","+- Page displays an error message containing \"insufficient\"","+- Customer remains on the checkout page","+- No JavaScript errors","+","+---","+","+### Test 9.13: Switches between payment method forms","+","+**Preconditions:** Product #1, reached Step 4 (payment) in checkout","+","+**Steps:**","+1. Complete checkout steps 1-3 (contact, address, shipping)","+2. Verify \"Credit Card\" is selected and card form fields are visible","+3. Click \"PayPal\" radio button","+4. Verify card form fields are hidden","+5. Verify \"Pay with PayPal\" button is visible","+6. Click \"Bank Transfer\" radio button","+7. Verify \"Place order\" button is visible","+8. Verify bank transfer information text is displayed","+","+**Expected Results:**","+- Payment form dynamically updates based on selected payment method","+- No JavaScript errors","+","+**Total: 13 tests**","+","+---","+"," ## Suite 10: Customer Account"," "," **File:** `tests/Browser/Storefront/CustomerAccountTest.php`"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"a8237502-0c9e-4892-8cba-083872f8ec80"}
{"parentUuid":"425b449c-806e-4cb7-8bbc-9e4b32f3334e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01LHwyGDoTiRDXuAoP8Pemu3","type":"message","role":"assistant","content":[{"type":"text","text":"Now add admin order management tests for confirm payment, fulfillment guard, and mark shipped/delivered. Let me add after test 4.7."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1646,"cache_read_input_tokens":139832,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1646},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WtnTbhyK468Jn9XepH","type":"assistant","uuid":"e662c9ab-7389-4476-b6bc-04cde7e7525a","timestamp":"2026-02-11T15:26:50.788Z"}
{"parentUuid":"e662c9ab-7389-4476-b6bc-04cde7e7525a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01LHwyGDoTiRDXuAoP8Pemu3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011HC6uME1f9VLRZ1ZcHBCCm","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management","new_string":"---\n\n### Test 4.8: Can confirm bank transfer payment\n\n**Preconditions:** Logged in as admin, Order #1005 exists with payment_method=bank_transfer and financial_status=pending\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n5. Verify \"Pending\" financial status badge is displayed\n6. Verify \"Confirm payment\" button is visible\n7. Click \"Confirm payment\"\n\n**Expected Results:**\n- Page displays \"Payment confirmed\"\n- Financial status badge changes to \"Paid\"\n- \"Confirm payment\" button is no longer visible\n- No JavaScript errors\n\n---\n\n### Test 4.9: Shows fulfillment guard for unpaid order\n\n**Preconditions:** Logged in as admin, Order #1005 exists with financial_status=pending (before payment confirmation)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n\n**Expected Results:**\n- Page displays a warning message about payment required before fulfillment\n- \"Create fulfillment\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 4.10: Can mark fulfillment as shipped\n\n**Preconditions:** Logged in as admin, Order #1001 is paid and has been fulfilled (fulfillment created in test 4.5)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Create a fulfillment first (if not already done via test 4.5)\n6. Click \"Mark as shipped\" on the fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Shipped\"\n- No JavaScript errors\n\n---\n\n### Test 4.11: Can mark fulfillment as delivered\n\n**Preconditions:** Logged in as admin, Order #1001 has a shipped fulfillment\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Mark as delivered\" on the shipped fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Delivered\"\n- Order fulfillment status updates to \"Fulfilled\"\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 5: Admin Discount Management"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1646,"cache_read_input_tokens":139832,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1646},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WtnTbhyK468Jn9XepH","type":"assistant","uuid":"16aaf1ad-3145-48f3-979d-847abb2c64d6","timestamp":"2026-02-11T15:27:00.133Z"}
{"parentUuid":"16aaf1ad-3145-48f3-979d-847abb2c64d6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_011HC6uME1f9VLRZ1ZcHBCCm","toolUseID":"toolu_011HC6uME1f9VLRZ1ZcHBCCm","timestamp":"2026-02-11T15:27:00.140Z","uuid":"c97f5255-d0f8-414d-a2bf-d406b241cd15"}
{"parentUuid":"16aaf1ad-3145-48f3-979d-847abb2c64d6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011HC6uME1f9VLRZ1ZcHBCCm","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"8eb34f75-de53-4372-ab73-80c6d52762ef","timestamp":"2026-02-11T15:27:00.139Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management","newString":"---\n\n### Test 4.8: Can confirm bank transfer payment\n\n**Preconditions:** Logged in as admin, Order #1005 exists with payment_method=bank_transfer and financial_status=pending\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n5. Verify \"Pending\" financial status badge is displayed\n6. Verify \"Confirm payment\" button is visible\n7. Click \"Confirm payment\"\n\n**Expected Results:**\n- Page displays \"Payment confirmed\"\n- Financial status badge changes to \"Paid\"\n- \"Confirm payment\" button is no longer visible\n- No JavaScript errors\n\n---\n\n### Test 4.9: Shows fulfillment guard for unpaid order\n\n**Preconditions:** Logged in as admin, Order #1005 exists with financial_status=pending (before payment confirmation)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n\n**Expected Results:**\n- Page displays a warning message about payment required before fulfillment\n- \"Create fulfillment\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 4.10: Can mark fulfillment as shipped\n\n**Preconditions:** Logged in as admin, Order #1001 is paid and has been fulfilled (fulfillment created in test 4.5)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Create a fulfillment first (if not already done via test 4.5)\n6. Click \"Mark as shipped\" on the fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Shipped\"\n- No JavaScript errors\n\n---\n\n### Test 4.11: Can mark fulfillment as delivered\n\n**Preconditions:** Logged in as admin, Order #1001 has a shipped fulfillment\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Mark as delivered\" on the shipped fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Delivered\"\n- Order fulfillment status updates to \"Fulfilled\"\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 5: Admin Discount Management","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout with credit card\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify \"Credit Card\" is pre-selected as payment method\n20. Enter card number: `4242 4242 4242 4242`\n21. Enter cardholder name: `Test Buyer`\n22. Enter expiry: `12/28`\n23. Enter CVC: `123`\n24. Verify total is 29.98 (24.99 + 4.99 shipping)\n25. Click \"Pay now\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n---\n\n### Test 9.9: Completes checkout with PayPal\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"PayPal\" radio button to select PayPal as payment method\n9. Click \"Pay with PayPal\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays \"PayPal\" in payment method section\n- No JavaScript errors\n\n---\n\n### Test 9.10: Completes checkout with bank transfer\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"Bank Transfer\" radio button to select Bank Transfer as payment method\n9. Click \"Place order\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays bank transfer instructions (IBAN, BIC, reference)\n- Confirmation page displays the order number as reference\n- No JavaScript errors\n\n---\n\n### Test 9.11: Shows error for declined credit card (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Verify \"Credit Card\" is selected\n9. Enter card number: `4000 0000 0000 0002` (magic decline card)\n10. Enter cardholder name: `Test Buyer`\n11. Enter expiry: `12/28`\n12. Enter CVC: `123`\n13. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"declined\"\n- Customer remains on the checkout page (NOT redirected to confirmation)\n- No JavaScript errors\n\n---\n\n### Test 9.12: Shows error for insufficient funds (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Enter card number: `4000 0000 0000 9995` (magic insufficient funds card)\n9. Enter cardholder name: `Test Buyer`\n10. Enter expiry: `12/28`\n11. Enter CVC: `123`\n12. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"insufficient\"\n- Customer remains on the checkout page\n- No JavaScript errors\n\n---\n\n### Test 9.13: Switches between payment method forms\n\n**Preconditions:** Product #1, reached Step 4 (payment) in checkout\n\n**Steps:**\n1. Complete checkout steps 1-3 (contact, address, shipping)\n2. Verify \"Credit Card\" is selected and card form fields are visible\n3. Click \"PayPal\" radio button\n4. Verify card form fields are hidden\n5. Verify \"Pay with PayPal\" button is visible\n6. Click \"Bank Transfer\" radio button\n7. Verify \"Place order\" button is visible\n8. Verify bank transfer information text is displayed\n\n**Expected Results:**\n- Payment form dynamically updates based on selected payment method\n- No JavaScript errors\n\n**Total: 13 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":826,"oldLines":10,"newStart":826,"newLines":84,"lines":[" - Page displays \"customer@acme.test\" in the customer info section"," - No JavaScript errors"," ","-**Total: 7 tests**","+---"," ","+### Test 4.8: Can confirm bank transfer payment","+","+**Preconditions:** Logged in as admin, Order #1005 exists with payment_method=bank_transfer and financial_status=pending","+","+**Steps:**","+1. Navigate to `/admin/login`","+2. Log in as admin","+3. Click \"Orders\" in the sidebar","+4. Click \"#1005\" to open the order detail","+5. Verify \"Pending\" financial status badge is displayed","+6. Verify \"Confirm payment\" button is visible","+7. Click \"Confirm payment\"","+","+**Expected Results:**","+- Page displays \"Payment confirmed\"","+- Financial status badge changes to \"Paid\"","+- \"Confirm payment\" button is no longer visible","+- No JavaScript errors","+"," ---"," ","+### Test 4.9: Shows fulfillment guard for unpaid order","+","+**Preconditions:** Logged in as admin, Order #1005 exists with financial_status=pending (before payment confirmation)","+","+**Steps:**","+1. Navigate to `/admin/login`","+2. Log in as admin","+3. Click \"Orders\" in the sidebar","+4. Click \"#1005\" to open the order detail","+","+**Expected Results:**","+- Page displays a warning message about payment required before fulfillment","+- \"Create fulfillment\" button is disabled or hidden","+- No JavaScript errors","+","+---","+","+### Test 4.10: Can mark fulfillment as shipped","+","+**Preconditions:** Logged in as admin, Order #1001 is paid and has been fulfilled (fulfillment created in test 4.5)","+","+**Steps:**","+1. Navigate to `/admin/login`","+2. Log in as admin","+3. Click \"Orders\" in the sidebar","+4. Click \"#1001\" to open the order detail","+5. Create a fulfillment first (if not already done via test 4.5)","+6. Click \"Mark as shipped\" on the fulfillment card","+","+**Expected Results:**","+- Fulfillment status badge changes to \"Shipped\"","+- No JavaScript errors","+","+---","+","+### Test 4.11: Can mark fulfillment as delivered","+","+**Preconditions:** Logged in as admin, Order #1001 has a shipped fulfillment","+","+**Steps:**","+1. Navigate to `/admin/login`","+2. Log in as admin","+3. Click \"Orders\" in the sidebar","+4. Click \"#1001\" to open the order detail","+5. Click \"Mark as delivered\" on the shipped fulfillment card","+","+**Expected Results:**","+- Fulfillment status badge changes to \"Delivered\"","+- Order fulfillment status updates to \"Fulfilled\"","+- No JavaScript errors","+","+**Total: 11 tests**","+","+---","+"," ## Suite 5: Admin Discount Management"," "," **File:** `tests/Browser/Admin/DiscountManagementTest.php`"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"16aaf1ad-3145-48f3-979d-847abb2c64d6"}
{"parentUuid":"8eb34f75-de53-4372-ab73-80c6d52762ef","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G3uY2TCS3RmaqJRhWqfiPB","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the dependency table to reflect Suite 9 depends on payment method selection."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":851,"cache_read_input_tokens":141478,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":851},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wuhw2A82431nF2KoJT","type":"assistant","uuid":"ba611330-2a62-487c-99d3-308855dfee7f","timestamp":"2026-02-11T15:27:03.232Z"}
{"parentUuid":"ba611330-2a62-487c-99d3-308855dfee7f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G3uY2TCS3RmaqJRhWqfiPB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YVTZU8sxdxUNHGgPPCeXvF","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":130,"limit":20}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":851,"cache_read_input_tokens":141478,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":851},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wuhw2A82431nF2KoJT","type":"assistant","uuid":"b2b7066f-e86e-46e2-94a5-77df1f8b065c","timestamp":"2026-02-11T15:27:04.085Z"}
{"parentUuid":"b2b7066f-e86e-46e2-94a5-77df1f8b065c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01YVTZU8sxdxUNHGgPPCeXvF","toolUseID":"toolu_01YVTZU8sxdxUNHGgPPCeXvF","timestamp":"2026-02-11T15:27:04.089Z","uuid":"970aa215-6a6a-4171-aa44-649d1af9c12d"}
{"parentUuid":"b2b7066f-e86e-46e2-94a5-77df1f8b065c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YVTZU8sxdxUNHGgPPCeXvF","type":"tool_result","content":"   130→    S7 --> S13[Suite 13: Responsive / Mobile]\n   131→    S7 --> S14[Suite 14: Accessibility]\n   132→```\n   133→\n   134→| Execution Phase | Suites | Rationale |\n   135→|-----------------|--------|-----------|\n   136→| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n   137→| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n   138→| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n   139→| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n   140→| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n   141→| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n   142→\n   143→---\n   144→\n   145→## Test Directory Structure\n   146→\n   147→```\n   148→tests/Browser/\n   149→    SmokeTest.php"}]},"uuid":"3046233c-fd18-4d64-aaa9-2a0b6eb3c89d","timestamp":"2026-02-11T15:27:04.089Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php","numLines":20,"startLine":130,"totalLines":3175}},"sourceToolAssistantUUID":"b2b7066f-e86e-46e2-94a5-77df1f8b065c"}
{"parentUuid":"3046233c-fd18-4d64-aaa9-2a0b6eb3c89d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01M3iqhL2VQQWiSuwuojJj15","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016NuSyw9DDaNy4FXM1jyAnJ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |","new_string":"| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working. Tests payment method selection (credit card, PayPal, bank transfer), magic card declines, and bank transfer instructions. |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":481,"cache_read_input_tokens":142329,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":481},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WuzVvasMAiVfPFMcX7","type":"assistant","uuid":"bb85ead7-b835-4409-8a46-9aab45c2c4d4","timestamp":"2026-02-11T15:27:08.412Z"}
{"parentUuid":"bb85ead7-b835-4409-8a46-9aab45c2c4d4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_016NuSyw9DDaNy4FXM1jyAnJ","toolUseID":"toolu_016NuSyw9DDaNy4FXM1jyAnJ","timestamp":"2026-02-11T15:27:08.418Z","uuid":"8b5baf60-a3ce-4a39-b252-0e32dee55264"}
{"parentUuid":"bb85ead7-b835-4409-8a46-9aab45c2c4d4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016NuSyw9DDaNy4FXM1jyAnJ","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"37456f2a-1c59-4c83-a485-c07a5f3092e6","timestamp":"2026-02-11T15:27:08.418Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |","newString":"| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working. Tests payment method selection (credit card, PayPal, bank transfer), magic card declines, and bank transfer instructions. |","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n---\n\n### Test 4.8: Can confirm bank transfer payment\n\n**Preconditions:** Logged in as admin, Order #1005 exists with payment_method=bank_transfer and financial_status=pending\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n5. Verify \"Pending\" financial status badge is displayed\n6. Verify \"Confirm payment\" button is visible\n7. Click \"Confirm payment\"\n\n**Expected Results:**\n- Page displays \"Payment confirmed\"\n- Financial status badge changes to \"Paid\"\n- \"Confirm payment\" button is no longer visible\n- No JavaScript errors\n\n---\n\n### Test 4.9: Shows fulfillment guard for unpaid order\n\n**Preconditions:** Logged in as admin, Order #1005 exists with financial_status=pending (before payment confirmation)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n\n**Expected Results:**\n- Page displays a warning message about payment required before fulfillment\n- \"Create fulfillment\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 4.10: Can mark fulfillment as shipped\n\n**Preconditions:** Logged in as admin, Order #1001 is paid and has been fulfilled (fulfillment created in test 4.5)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Create a fulfillment first (if not already done via test 4.5)\n6. Click \"Mark as shipped\" on the fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Shipped\"\n- No JavaScript errors\n\n---\n\n### Test 4.11: Can mark fulfillment as delivered\n\n**Preconditions:** Logged in as admin, Order #1001 has a shipped fulfillment\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Mark as delivered\" on the shipped fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Delivered\"\n- Order fulfillment status updates to \"Fulfilled\"\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout with credit card\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify \"Credit Card\" is pre-selected as payment method\n20. Enter card number: `4242 4242 4242 4242`\n21. Enter cardholder name: `Test Buyer`\n22. Enter expiry: `12/28`\n23. Enter CVC: `123`\n24. Verify total is 29.98 (24.99 + 4.99 shipping)\n25. Click \"Pay now\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n---\n\n### Test 9.9: Completes checkout with PayPal\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"PayPal\" radio button to select PayPal as payment method\n9. Click \"Pay with PayPal\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays \"PayPal\" in payment method section\n- No JavaScript errors\n\n---\n\n### Test 9.10: Completes checkout with bank transfer\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"Bank Transfer\" radio button to select Bank Transfer as payment method\n9. Click \"Place order\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays bank transfer instructions (IBAN, BIC, reference)\n- Confirmation page displays the order number as reference\n- No JavaScript errors\n\n---\n\n### Test 9.11: Shows error for declined credit card (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Verify \"Credit Card\" is selected\n9. Enter card number: `4000 0000 0000 0002` (magic decline card)\n10. Enter cardholder name: `Test Buyer`\n11. Enter expiry: `12/28`\n12. Enter CVC: `123`\n13. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"declined\"\n- Customer remains on the checkout page (NOT redirected to confirmation)\n- No JavaScript errors\n\n---\n\n### Test 9.12: Shows error for insufficient funds (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Enter card number: `4000 0000 0000 9995` (magic insufficient funds card)\n9. Enter cardholder name: `Test Buyer`\n10. Enter expiry: `12/28`\n11. Enter CVC: `123`\n12. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"insufficient\"\n- Customer remains on the checkout page\n- No JavaScript errors\n\n---\n\n### Test 9.13: Switches between payment method forms\n\n**Preconditions:** Product #1, reached Step 4 (payment) in checkout\n\n**Steps:**\n1. Complete checkout steps 1-3 (contact, address, shipping)\n2. Verify \"Credit Card\" is selected and card form fields are visible\n3. Click \"PayPal\" radio button\n4. Verify card form fields are hidden\n5. Verify \"Pay with PayPal\" button is visible\n6. Click \"Bank Transfer\" radio button\n7. Verify \"Place order\" button is visible\n8. Verify bank transfer information text is displayed\n\n**Expected Results:**\n- Payment form dynamically updates based on selected payment method\n- No JavaScript errors\n\n**Total: 13 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":137,"oldLines":7,"newStart":137,"newLines":7,"lines":[" | Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |"," | Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |"," | Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |","-| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working |","+| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working. Tests payment method selection (credit card, PayPal, bank transfer), magic card declines, and bank transfer instructions. |"," | Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |"," "," ---"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"bb85ead7-b835-4409-8a46-9aab45c2c4d4"}
{"parentUuid":"37456f2a-1c59-4c83-a485-c07a5f3092e6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CLJvSydgLeu8kWkFZZrgYw","type":"message","role":"assistant","content":[{"type":"text","text":"Task #7 done. Now the final task - Task #8 (09-IMPLEMENTATION-ROADMAP.md)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":255,"cache_read_input_tokens":142810,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":255},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WvKNEpXpE1r8kpxStg","type":"assistant","uuid":"436408a1-7dca-4ee3-89b8-d0dc4e777ed2","timestamp":"2026-02-11T15:27:12.022Z"}
{"parentUuid":"436408a1-7dca-4ee3-89b8-d0dc4e777ed2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CLJvSydgLeu8kWkFZZrgYw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NRFXAsYBUvwdueEeoS211g","name":"TaskUpdate","input":{"taskId":"7","status":"completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":255,"cache_read_input_tokens":142810,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":255},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WvKNEpXpE1r8kpxStg","type":"assistant","uuid":"d9c04735-3b16-4e9d-9b96-bc467af25dd0","timestamp":"2026-02-11T15:27:12.532Z"}
{"parentUuid":"d9c04735-3b16-4e9d-9b96-bc467af25dd0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NRFXAsYBUvwdueEeoS211g","type":"tool_result","content":"Updated task #7 status"}]},"uuid":"ceaa8b9e-690e-4198-90da-64a7585af280","timestamp":"2026-02-11T15:27:12.535Z","toolUseResult":{"success":true,"taskId":"7","updatedFields":["status"],"statusChange":{"from":"in_progress","to":"completed"}},"sourceToolAssistantUUID":"d9c04735-3b16-4e9d-9b96-bc467af25dd0"}
{"parentUuid":"ceaa8b9e-690e-4198-90da-64a7585af280","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CLJvSydgLeu8kWkFZZrgYw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BffJtqQo2hZJtRpEeorXTs","name":"TaskUpdate","input":{"taskId":"8","status":"in_progress"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":255,"cache_read_input_tokens":142810,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":255},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WvKNEpXpE1r8kpxStg","type":"assistant","uuid":"86940c31-8841-461c-88df-e59b2cb928ea","timestamp":"2026-02-11T15:27:13.039Z"}
{"parentUuid":"86940c31-8841-461c-88df-e59b2cb928ea","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BffJtqQo2hZJtRpEeorXTs","type":"tool_result","content":"Updated task #8 status"}]},"uuid":"ecb8f4a9-34d5-4791-882b-1e4f7e66044a","timestamp":"2026-02-11T15:27:13.041Z","toolUseResult":{"success":true,"taskId":"8","updatedFields":["status"],"statusChange":{"from":"pending","to":"in_progress"}},"sourceToolAssistantUUID":"86940c31-8841-461c-88df-e59b2cb928ea"}
{"type":"file-history-snapshot","messageId":"053183b5-ba32-4e15-8fc1-f29e3dfb3ac9","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v1","version":1,"backupTime":"2026-02-11T15:14:23.948Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v1","version":1,"backupTime":"2026-02-11T15:16:45.432Z"},"specs/03-ADMIN-UI.md":{"backupFileName":"a791bee027d52553@v1","version":1,"backupTime":"2026-02-11T15:21:51.319Z"},"specs/04-STOREFRONT-UI.md":{"backupFileName":"401411061d8a6ac1@v1","version":1,"backupTime":"2026-02-11T15:22:26.455Z"},"specs/07-SEEDERS-AND-TEST-DATA.md":{"backupFileName":"c11f09e3e5d3a009@v1","version":1,"backupTime":"2026-02-11T15:23:20.806Z"},"specs/08-PLAYWRIGHT-E2E-PLAN.md":{"backupFileName":"2744918423fe477c@v1","version":1,"backupTime":"2026-02-11T15:25:53.175Z"},"specs/09-IMPLEMENTATION-ROADMAP.md":{"backupFileName":"1f0335ca0ea0d4c4@v1","version":1,"backupTime":"2026-02-11T15:27:18.885Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"ecb8f4a9-34d5-4791-882b-1e4f7e66044a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PPz7UJDnSJpAmf4T1h5Urv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GY1xPgtitnn2kEmA6vYxtQ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| Payments | Stripe + PayPal | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |","new_string":"| Payments | Stripe + PayPal | Mock PSP (in-process) | No external API calls. MockPaymentProvider handles credit card, PayPal, bank transfer with magic card numbers for testing. |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":226,"cache_read_input_tokens":143065,"output_tokens":214,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":226,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2Wvf8suu4ZpeWChh7iF","type":"assistant","uuid":"053183b5-ba32-4e15-8fc1-f29e3dfb3ac9","timestamp":"2026-02-11T15:27:18.882Z"}
{"parentUuid":"053183b5-ba32-4e15-8fc1-f29e3dfb3ac9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01GY1xPgtitnn2kEmA6vYxtQ","toolUseID":"toolu_01GY1xPgtitnn2kEmA6vYxtQ","timestamp":"2026-02-11T15:27:18.891Z","uuid":"45cfa6c2-1879-45fa-ac45-a68dd7547797"}
{"parentUuid":"053183b5-ba32-4e15-8fc1-f29e3dfb3ac9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GY1xPgtitnn2kEmA6vYxtQ","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"e052c229-aeb3-4973-a7f8-1eb2ac50a700","timestamp":"2026-02-11T15:27:18.890Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| Payments | Stripe + PayPal | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |","newString":"| Payments | Stripe + PayPal | Mock PSP (in-process) | No external API calls. MockPaymentProvider handles credit card, PayPal, bank transfer with magic card numbers for testing. |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| Payments | Stripe + PayPal | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |\n| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentProvider` | `Stripe`, `Paypal` |\n| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n\n**StripePaymentProvider requirements:**\n- Creates Stripe Checkout Sessions in test mode\n- Webhook handler with HMAC signature verification\n- Idempotent event processing (checks `provider_payment_id` uniqueness)\n\n**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":22,"oldLines":7,"newStart":22,"newLines":7,"lines":[" | Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |"," | Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |"," | OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |","-| Payments | Stripe + PayPal | Stripe test mode + PayPal stub | Stripe with test keys, PayPal interface only |","+| Payments | Stripe + PayPal | Mock PSP (in-process) | No external API calls. MockPaymentProvider handles credit card, PayPal, bank transfer with magic card numbers for testing. |"," | Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |"," | Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |"," | CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"053183b5-ba32-4e15-8fc1-f29e3dfb3ac9"}
{"parentUuid":"e052c229-aeb3-4973-a7f8-1eb2ac50a700","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01JF9XadcMxaetcu2VCbFYaA","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the enums and payment service in Phase 5."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":259,"cache_read_input_tokens":143291,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":259},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Ww5owWVnFGHdT5SGbP","type":"assistant","uuid":"d797b68a-0348-442a-825b-06c0330933d4","timestamp":"2026-02-11T15:27:21.870Z"}
{"parentUuid":"d797b68a-0348-442a-825b-06c0330933d4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01JF9XadcMxaetcu2VCbFYaA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01F4nSK9486p6ZpJr8UoCVLJ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| `PaymentProvider` | `Stripe`, `Paypal` |\n| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |","new_string":"| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":259,"cache_read_input_tokens":143291,"output_tokens":245,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":259,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2Ww5owWVnFGHdT5SGbP","type":"assistant","uuid":"29eb649e-912b-4b55-a6b9-6b5ff4d7d191","timestamp":"2026-02-11T15:27:24.205Z"}
{"parentUuid":"29eb649e-912b-4b55-a6b9-6b5ff4d7d191","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01F4nSK9486p6ZpJr8UoCVLJ","toolUseID":"toolu_01F4nSK9486p6ZpJr8UoCVLJ","timestamp":"2026-02-11T15:27:24.218Z","uuid":"305e533b-4750-4b66-b52c-2f8a8f2176f0"}
{"parentUuid":"29eb649e-912b-4b55-a6b9-6b5ff4d7d191","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01F4nSK9486p6ZpJr8UoCVLJ","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"820c131f-7b47-4a04-ab56-f2dc8cc1ff6b","timestamp":"2026-02-11T15:27:24.217Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| `PaymentProvider` | `Stripe`, `Paypal` |\n| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |","newString":"| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentProvider` | `Stripe`, `Paypal` |\n| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n\n**StripePaymentProvider requirements:**\n- Creates Stripe Checkout Sessions in test mode\n- Webhook handler with HMAC signature verification\n- Idempotent event processing (checks `provider_payment_id` uniqueness)\n\n**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":646,"oldLines":8,"newStart":646,"newLines":8,"lines":[" | `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |"," | `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |"," | `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |","-| `PaymentProvider` | `Stripe`, `Paypal` |","-| `PaymentStatus` | `RequiresAction`, `Authorized`, `Captured`, `Failed`, `Refunded` |","+| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |","+| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |"," | `RefundStatus` | `Pending`, `Processed`, `Failed` |"," | `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"29eb649e-912b-4b55-a6b9-6b5ff4d7d191"}
{"parentUuid":"820c131f-7b47-4a04-ab56-f2dc8cc1ff6b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01A69vp5H38UjY44u4pekokr","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018K384vBnkSZ7JzCrWfSwua","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"#### Step 5.3: Payment Service\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n\n**StripePaymentProvider requirements:**\n- Creates Stripe Checkout Sessions in test mode\n- Webhook handler with HMAC signature verification\n- Idempotent event processing (checks `provider_payment_id` uniqueness)\n\n**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.","new_string":"#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":290,"cache_read_input_tokens":143550,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":290},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WwUYvJJ1nVAVcSuvbk","type":"assistant","uuid":"24186242-f920-4d2f-80e8-d1756b8b1c65","timestamp":"2026-02-11T15:27:44.713Z"}
{"parentUuid":"24186242-f920-4d2f-80e8-d1756b8b1c65","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_018K384vBnkSZ7JzCrWfSwua","toolUseID":"toolu_018K384vBnkSZ7JzCrWfSwua","timestamp":"2026-02-11T15:27:44.720Z","uuid":"32b7438c-025a-4d02-8bc2-26ddf1839271"}
{"parentUuid":"24186242-f920-4d2f-80e8-d1756b8b1c65","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018K384vBnkSZ7JzCrWfSwua","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"0fa16bd2-1cd6-4592-a5a7-16a2cbe43a5b","timestamp":"2026-02-11T15:27:44.719Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"#### Step 5.3: Payment Service\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n\n**StripePaymentProvider requirements:**\n- Creates Stripe Checkout Sessions in test mode\n- Webhook handler with HMAC signature verification\n- Idempotent event processing (checks `provider_payment_id` uniqueness)\n\n**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.","newString":"#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |\n| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |\n| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |\n| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |\n\n**StripePaymentProvider requirements:**\n- Creates Stripe Checkout Sessions in test mode\n- Webhook handler with HMAC signature verification\n- Idempotent event processing (checks `provider_payment_id` uniqueness)\n\n**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":651,"oldLines":31,"newStart":651,"newLines":48,"lines":[" | `RefundStatus` | `Pending`, `Processed`, `Failed` |"," | `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |"," ","-#### Step 5.3: Payment Service","+#### Step 5.3: Payment Service (Mock PSP)"," "," **Files to create:**"," "," | Type | Name | Purpose |"," |------|------|---------|","-| Interface | `App\\Contracts\\PaymentProvider` | Defines createSession, handleWebhook, refund methods |","-| Service | `App\\Services\\Payments\\StripePaymentProvider` | Implements PaymentProvider with Stripe Checkout Sessions in test mode |","-| Service | `App\\Services\\Payments\\PaypalPaymentProvider` | Stub implementation that throws NotImplementedException |","+| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |","+| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |","+| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |"," "," **PaymentProvider interface methods:**"," "," | Method | Description |"," |--------|-------------|","-| `createSession(Checkout $checkout, int $amount, string $currency): PaymentSession` | Creates payment session with the provider |","-| `handleWebhook(Request $request): void` | Processes incoming webhook from provider |","-| `refund(Payment $payment, int $amount): RefundResult` | Issues a refund through the provider |","+| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |","+| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |"," ","-**StripePaymentProvider requirements:**","-- Creates Stripe Checkout Sessions in test mode","-- Webhook handler with HMAC signature verification","-- Idempotent event processing (checks `provider_payment_id` uniqueness)","+**MockPaymentProvider requirements:**","+- No external API calls, no webhooks, no redirects","+- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)","+- Credit card / PayPal: instant capture, returns PaymentResult with status=captured","+- Bank transfer: deferred capture, returns PaymentResult with status=pending","+- Generates mock reference IDs (`mock_` + random string)"," ","-**Registration:** Bind `PaymentProvider` interface to `StripePaymentProvider` in `AppServiceProvider`.","+**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`."," ","+#### Step 5.3b: Admin Payment Confirmation","+","+**Files to create:**","+","+| Type | Name | Purpose |","+|------|------|---------|","+| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |","+","+**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):","+1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`","+2. Update `financial_status` to `paid`, `status` to `paid`","+3. Update payment record status to `captured`","+4. Commit reserved inventory","+5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment","+6. Dispatch `OrderPaid` event","+"," #### Step 5.4: Order Service"," "," **File to create:** `App\\Services\\OrderService`"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"24186242-f920-4d2f-80e8-d1756b8b1c65"}
{"parentUuid":"0fa16bd2-1cd6-4592-a5a7-16a2cbe43a5b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01DqtbEVAc5TgsNHB6EDPHXa","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the FulfillmentService section."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1078,"cache_read_input_tokens":143840,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1078},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WxzZEWWfnoXdrU1h6m","type":"assistant","uuid":"398da0c0-a8ed-4201-aea5-f9f5267b36d5","timestamp":"2026-02-11T15:27:47.756Z"}
{"parentUuid":"398da0c0-a8ed-4201-aea5-f9f5267b36d5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01DqtbEVAc5TgsNHB6EDPHXa","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GPfp1jEuTcu6HuCkcdjsve","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).","new_string":"#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders)."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1078,"cache_read_input_tokens":143840,"output_tokens":593,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1078,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2WxzZEWWfnoXdrU1h6m","type":"assistant","uuid":"79ed0b54-c2c6-484b-a26f-ab7176625107","timestamp":"2026-02-11T15:27:55.441Z"}
{"parentUuid":"79ed0b54-c2c6-484b-a26f-ab7176625107","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01GPfp1jEuTcu6HuCkcdjsve","toolUseID":"toolu_01GPfp1jEuTcu6HuCkcdjsve","timestamp":"2026-02-11T15:27:55.449Z","uuid":"29b684b3-7fb7-40c5-ab4c-efd81ae6626b"}
{"parentUuid":"79ed0b54-c2c6-484b-a26f-ab7176625107","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GPfp1jEuTcu6HuCkcdjsve","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"05a79d44-c323-4a6c-bbf0-4895e597863c","timestamp":"2026-02-11T15:27:55.448Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).","newString":"#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |\n| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |\n| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":723,"oldLines":12,"newStart":723,"newLines":14,"lines":[" "," | Method | Description |"," |--------|-------------|","-| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities |","-| `ship(Fulfillment $fulfillment, array $tracking): void` | Sets tracking info, transitions to `shipped` |","-| `deliver(Fulfillment $fulfillment): void` | Transitions to `delivered` |","+| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |","+| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |","+| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |"," "," Updates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`)."," ","+**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).","+"," **Events to create:**"," "," | Type | Name | Purpose |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"79ed0b54-c2c6-484b-a26f-ab7176625107"}
{"parentUuid":"05a79d44-c323-4a6c-bbf0-4895e597863c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_019w7byJFo1wEGQM31ALxrN3","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me also check if there are any remaining Stripe/PayPal references in the roadmap file."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":638,"cache_read_input_tokens":144918,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":638},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wyn4gS82mt5zSVYu7r","type":"assistant","uuid":"c60f4da1-5a2f-4e4d-9e5b-e11b27c230c3","timestamp":"2026-02-11T15:27:59.374Z"}
{"parentUuid":"c60f4da1-5a2f-4e4d-9e5b-e11b27c230c3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_019w7byJFo1wEGQM31ALxrN3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Sp7qcCT2v6wjUL4yvhEYct","name":"Grep","input":{"pattern":"(?i)(stripe|paypal)","path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","output_mode":"content","-n":true}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":638,"cache_read_input_tokens":144918,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":638},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wyn4gS82mt5zSVYu7r","type":"assistant","uuid":"c5f4f376-bc07-4606-a229-ae0853b95512","timestamp":"2026-02-11T15:28:00.544Z"}
{"parentUuid":"c5f4f376-bc07-4606-a229-ae0853b95512","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_01Sp7qcCT2v6wjUL4yvhEYct","toolUseID":"toolu_01Sp7qcCT2v6wjUL4yvhEYct","timestamp":"2026-02-11T15:28:00.806Z","uuid":"4ce6a73f-716b-469d-9f39-7fab2cb6d557"}
{"parentUuid":"c5f4f376-bc07-4606-a229-ae0853b95512","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Sp7qcCT2v6wjUL4yvhEYct","type":"tool_result","content":"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. |\n649:| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n660:| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n668:| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n674:- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n996:| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n1433:#### tests/Feature/Payments/StripeWebhookTest.php\n1437:| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n1440:| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n1450:| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n1451:| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n1885:| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n1962:| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n1963:| `Payments\\PaypalPaymentProvider` | Stub implementation |"}]},"uuid":"3630b3bf-9646-492c-b3ae-229cf697a051","timestamp":"2026-02-11T15:28:00.805Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"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. |\n649:| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n660:| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n668:| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n674:- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n996:| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n1433:#### tests/Feature/Payments/StripeWebhookTest.php\n1437:| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n1440:| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n1450:| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n1451:| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n1885:| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n1962:| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n1963:| `Payments\\PaypalPaymentProvider` | Stub implementation |","numLines":14},"sourceToolAssistantUUID":"c5f4f376-bc07-4606-a229-ae0853b95512"}
{"parentUuid":"3630b3bf-9646-492c-b3ae-229cf697a051","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RkKUaJo9CbNTMSRu2W8heD","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"There are more Stripe/PayPal references in the roadmap. Let me read those sections and fix them.","signature":"EogCCkYICxgCKkC16ngZvzAbmVO/6CMTho2gmUXdPY//GrNeL67RWvXYQ1+e88EHkzv4YqW8hx8mt3tClolD13EmOmYk7AaaaPtsEgykrWNGaMdj9WUYTPUaDClLor+KQwtUSfHSpiIwXd4oY446abiQAMgSv8Gjult8/kZ75R2GejLbppLLzaEdXrZ3w8w2kLhnPHAkayZrKnATMYDZb6R9w6Ibw3Wtfo0mAYosbRvfQztJpKrBoPryuAPRgmA+hmLl20R7CaKFswWHRaxPyR1UnU5QGjKaz640asEX/l8OmXE3DlGxQGDg0+inIem+1CISeDj77WUBz+M8QZroK/CPiwMVee7E2iN9GAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":633,"cache_read_input_tokens":145556,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":633},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WzAu7V1PRdLe8dwafS","type":"assistant","uuid":"697af6c9-359e-41ca-97d0-a0825670bb7b","timestamp":"2026-02-11T15:28:03.843Z"}
{"parentUuid":"697af6c9-359e-41ca-97d0-a0825670bb7b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RkKUaJo9CbNTMSRu2W8heD","type":"message","role":"assistant","content":[{"type":"text","text":"There are still Stripe/PayPal references in the test sections. Let me fix them."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":633,"cache_read_input_tokens":145556,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":633},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WzAu7V1PRdLe8dwafS","type":"assistant","uuid":"201b22f2-5270-4cd0-a5a3-fdf832ab0c20","timestamp":"2026-02-11T15:28:04.446Z"}
{"parentUuid":"201b22f2-5270-4cd0-a5a3-fdf832ab0c20","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RkKUaJo9CbNTMSRu2W8heD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016cHfVtQCYEfRTQR3H6iZeg","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":990,"limit":20}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":633,"cache_read_input_tokens":145556,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":633},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WzAu7V1PRdLe8dwafS","type":"assistant","uuid":"4362ab29-e526-40e4-9a92-d90486a40966","timestamp":"2026-02-11T15:28:05.119Z"}
{"parentUuid":"4362ab29-e526-40e4-9a92-d90486a40966","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_016cHfVtQCYEfRTQR3H6iZeg","toolUseID":"toolu_016cHfVtQCYEfRTQR3H6iZeg","timestamp":"2026-02-11T15:28:05.121Z","uuid":"810dc7db-5988-4b06-82d1-b8378f55ea1d"}
{"parentUuid":"4362ab29-e526-40e4-9a92-d90486a40966","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016cHfVtQCYEfRTQR3H6iZeg","type":"tool_result","content":"   990→| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n   991→| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n   992→| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n   993→| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n   994→| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n   995→| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n   996→| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n   997→| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n   998→| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n   999→| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n  1000→| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n  1001→| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n  1002→| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n  1003→| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n  1004→\n  1005→---\n  1006→\n  1007→### Unit Tests\n  1008→\n  1009→---"}]},"uuid":"a921af21-d184-4a31-8dcc-10fd1b64b2ec","timestamp":"2026-02-11T15:28:05.121Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---","numLines":20,"startLine":990,"totalLines":2046}},"sourceToolAssistantUUID":"4362ab29-e526-40e4-9a92-d90486a40966"}
{"parentUuid":"a921af21-d184-4a31-8dcc-10fd1b64b2ec","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RkKUaJo9CbNTMSRu2W8heD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PwkvbneLiicb1P4ArUjKd6","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1425,"limit":50}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":633,"cache_read_input_tokens":145556,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":633},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WzAu7V1PRdLe8dwafS","type":"assistant","uuid":"e5ce7ad9-e0d6-48ed-a847-989037ca6269","timestamp":"2026-02-11T15:28:05.982Z"}
{"parentUuid":"e5ce7ad9-e0d6-48ed-a847-989037ca6269","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01PwkvbneLiicb1P4ArUjKd6","toolUseID":"toolu_01PwkvbneLiicb1P4ArUjKd6","timestamp":"2026-02-11T15:28:05.985Z","uuid":"4bd51c2f-eab2-4eed-bf17-a0c824dea2ba"}
{"parentUuid":"e5ce7ad9-e0d6-48ed-a847-989037ca6269","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PwkvbneLiicb1P4ArUjKd6","type":"tool_result","content":"  1425→| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n  1426→| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n  1427→| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n  1428→| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n  1429→| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n  1430→\n  1431→---\n  1432→\n  1433→#### tests/Feature/Payments/StripeWebhookTest.php\n  1434→\n  1435→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1436→|-----------|----------------------|----------------|----------|\n  1437→| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n  1438→| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n  1439→| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n  1440→| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n  1441→| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n  1442→| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n  1443→\n  1444→---\n  1445→\n  1446→#### tests/Feature/Payments/PaymentServiceTest.php\n  1447→\n  1448→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1449→|-----------|----------------------|----------------|----------|\n  1450→| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n  1451→| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n  1452→| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n  1453→| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n  1454→| updates payment status on capture | process successful payment callback | payment status = captured | happy path |\n  1455→\n  1456→---\n  1457→\n  1458→#### tests/Feature/Customers/CustomerAccountTest.php\n  1459→\n  1460→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1461→|-----------|----------------------|----------------|----------|\n  1462→| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n  1463→| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n  1464→| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n  1465→| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n  1466→| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n  1467→| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n  1468→\n  1469→---\n  1470→\n  1471→#### tests/Feature/Customers/AddressManagementTest.php\n  1472→\n  1473→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1474→|-----------|----------------------|----------------|----------|"}]},"uuid":"817fff3d-e2de-40f4-8ae1-328e66804f8b","timestamp":"2026-02-11T15:28:05.984Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|","numLines":50,"startLine":1425,"totalLines":2046}},"sourceToolAssistantUUID":"e5ce7ad9-e0d6-48ed-a847-989037ca6269"}
{"parentUuid":"817fff3d-e2de-40f4-8ae1-328e66804f8b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RkKUaJo9CbNTMSRu2W8heD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NZcUAbxUwTgp53aqsXoAo1","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1875,"limit":20}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":633,"cache_read_input_tokens":145556,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":633},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WzAu7V1PRdLe8dwafS","type":"assistant","uuid":"7e953f3b-ae63-43bc-a0f8-09ddffe4a3ae","timestamp":"2026-02-11T15:28:06.623Z"}
{"parentUuid":"7e953f3b-ae63-43bc-a0f8-09ddffe4a3ae","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01NZcUAbxUwTgp53aqsXoAo1","toolUseID":"toolu_01NZcUAbxUwTgp53aqsXoAo1","timestamp":"2026-02-11T15:28:06.627Z","uuid":"38399205-71e4-4edd-826f-8b24f4cd82da"}
{"parentUuid":"7e953f3b-ae63-43bc-a0f8-09ddffe4a3ae","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NZcUAbxUwTgp53aqsXoAo1","type":"tool_result","content":"  1875→| `Cart/CartApiTest` | Cart API feature tests |\n  1876→| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n  1877→| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n  1878→| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n  1879→| `Checkout/DiscountTest` | Checkout discount feature tests |\n  1880→| `Checkout/ShippingTest` | Checkout shipping feature tests |\n  1881→| `Checkout/TaxTest` | Checkout tax feature tests |\n  1882→| `Orders/OrderCreationTest` | Order creation feature tests |\n  1883→| `Orders/RefundTest` | Refund feature tests |\n  1884→| `Orders/FulfillmentTest` | Fulfillment feature tests |\n  1885→| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n  1886→| `Payments/PaymentServiceTest` | Payment service feature tests |\n  1887→| `Customers/CustomerAccountTest` | Customer account feature tests |\n  1888→| `Customers/AddressManagementTest` | Address management feature tests |\n  1889→| `Admin/DashboardTest` | Admin dashboard feature tests |\n  1890→| `Admin/ProductManagementTest` | Admin product management feature tests |\n  1891→| `Admin/OrderManagementTest` | Admin order management feature tests |\n  1892→| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n  1893→| `Admin/SettingsTest` | Admin settings feature tests |\n  1894→| `Search/SearchTest` | Search feature tests |"}]},"uuid":"3e8da8a3-40a2-4713-8ad3-aa25bd0ce98c","timestamp":"2026-02-11T15:28:06.626Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |","numLines":20,"startLine":1875,"totalLines":2046}},"sourceToolAssistantUUID":"7e953f3b-ae63-43bc-a0f8-09ddffe4a3ae"}
{"parentUuid":"3e8da8a3-40a2-4713-8ad3-aa25bd0ce98c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RkKUaJo9CbNTMSRu2W8heD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VUT6PURYBXDBbcbaG651Ea","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1955,"limit":20}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":633,"cache_read_input_tokens":145556,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":633},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2WzAu7V1PRdLe8dwafS","type":"assistant","uuid":"5b2d3d8b-119d-4e19-b6c0-5451d58b3af6","timestamp":"2026-02-11T15:28:07.336Z"}
{"parentUuid":"5b2d3d8b-119d-4e19-b6c0-5451d58b3af6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01VUT6PURYBXDBbcbaG651Ea","toolUseID":"toolu_01VUT6PURYBXDBbcbaG651Ea","timestamp":"2026-02-11T15:28:07.339Z","uuid":"3000a405-63b7-4fee-bf81-33bfe737cc12"}
{"parentUuid":"5b2d3d8b-119d-4e19-b6c0-5451d58b3af6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VUT6PURYBXDBbcbaG651Ea","type":"tool_result","content":"  1955→| `OrderService` | Order creation from checkout, numbering, inventory commit |\n  1956→| `RefundService` | Refund creation, provider API, inventory restock |\n  1957→| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n  1958→| `NavigationService` | Menu tree building, URL resolution, caching |\n  1959→| `SearchService` | FTS5 index management, query execution, autocomplete |\n  1960→| `AnalyticsService` | Event tracking, daily aggregation |\n  1961→| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n  1962→| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n  1963→| `Payments\\PaypalPaymentProvider` | Stub implementation |\n  1964→\n  1965→---\n  1966→\n  1967→## 6. File Count Estimate\n  1968→\n  1969→| Category | Estimated Files |\n  1970→|----------|-----------------|\n  1971→| Migrations | 28-32 |\n  1972→| Models | 32-36 |\n  1973→| Livewire Components (Admin) | 28-30 |\n  1974→| Livewire Components (Storefront) | 15-17 |"}]},"uuid":"2901f4a1-97ee-4b96-a635-15557977eb2a","timestamp":"2026-02-11T15:28:07.339Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |","numLines":20,"startLine":1955,"totalLines":2046}},"sourceToolAssistantUUID":"5b2d3d8b-119d-4e19-b6c0-5451d58b3af6"}
{"parentUuid":"2901f4a1-97ee-4b96-a635-15557977eb2a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01DSsxY6uqBhPoDz441sdP67","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Wpk2TGBEscn7KTonAkzEcc","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |","new_string":"| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3292,"cache_read_input_tokens":146189,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3292},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wzevb8jzHhnv6WXPtv","type":"assistant","uuid":"d7ddce15-1435-4dc6-87fd-0e671d134587","timestamp":"2026-02-11T15:28:12.193Z"}
{"parentUuid":"d7ddce15-1435-4dc6-87fd-0e671d134587","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Wpk2TGBEscn7KTonAkzEcc","toolUseID":"toolu_01Wpk2TGBEscn7KTonAkzEcc","timestamp":"2026-02-11T15:28:12.199Z","uuid":"888fff01-db44-41bc-a83c-4e483667badc"}
{"parentUuid":"d7ddce15-1435-4dc6-87fd-0e671d134587","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Wpk2TGBEscn7KTonAkzEcc","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"6dfdf6d6-dfbe-4d2d-ad93-832efa133f7f","timestamp":"2026-02-11T15:28:12.199Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |","newString":"| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":993,"oldLines":7,"newStart":993,"newLines":7,"lines":[" | `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |"," | `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |"," | `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |","-| `tests/Feature/Payments/` | `StripeWebhookTest.php`, `PaymentServiceTest.php` |","+| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |"," | `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |"," | `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |"," | `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"d7ddce15-1435-4dc6-87fd-0e671d134587"}
{"parentUuid":"6dfdf6d6-dfbe-4d2d-ad93-832efa133f7f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01DSsxY6uqBhPoDz441sdP67","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PNu5FbWU9j84MJUTNQa4bz","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |","new_string":"#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3292,"cache_read_input_tokens":146189,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3292},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Wzevb8jzHhnv6WXPtv","type":"assistant","uuid":"9cc1c37d-7aaa-4c82-a01f-64eb0e46bad2","timestamp":"2026-02-11T15:28:27.263Z"}
{"parentUuid":"9cc1c37d-7aaa-4c82-a01f-64eb0e46bad2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01PNu5FbWU9j84MJUTNQa4bz","toolUseID":"toolu_01PNu5FbWU9j84MJUTNQa4bz","timestamp":"2026-02-11T15:28:27.268Z","uuid":"c44f81fc-fa7a-4bd1-a3a3-60f9b093f302"}
{"parentUuid":"9cc1c37d-7aaa-4c82-a01f-64eb0e46bad2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PNu5FbWU9j84MJUTNQa4bz","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"0a14181e-e75b-4501-9751-561d1aa52053","timestamp":"2026-02-11T15:28:27.268Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |","newString":"#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/StripeWebhookTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |\n| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |\n| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |\n| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |\n| is idempotent for duplicate events | send same event twice | only one order created | edge case |\n| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |\n| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |\n| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |\n| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |\n| updates payment status on capture | process successful payment callback | payment status = captured | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":1430,"oldLines":16,"newStart":1430,"newLines":16,"lines":[" "," ---"," ","-#### tests/Feature/Payments/StripeWebhookTest.php","+#### tests/Feature/Payments/MockPaymentProviderTest.php"," "," | Test Name | Setup / Preconditions | Key Assertions | Category |"," |-----------|----------------------|----------------|----------|","-| handles checkout.session.completed webhook | POST /api/webhooks/stripe with valid signature and checkout.session.completed event | checkout completed, order created | happy path |","-| rejects webhook with invalid signature | POST with wrong signature | 400 response | validation |","-| processes payment_intent.succeeded event | payment intent succeeded | payment status = captured, order financial_status = paid | happy path |","-| handles refund.created webhook | refund webhook from Stripe | refund record created/updated | happy path |","-| is idempotent for duplicate events | send same event twice | only one order created | edge case |","-| handles payment_intent.payment_failed event | payment failed webhook | payment status = failed, checkout remains in payment_pending | edge case |","+| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |","+| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |","+| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |","+| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |","+| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |","+| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |"," "," ---"," "]},{"oldStart":1447,"oldLines":14,"newStart":1447,"newLines":27,"lines":[" "," | Test Name | Setup / Preconditions | Key Assertions | Category |"," |-----------|----------------------|----------------|----------|","-| creates a Stripe checkout session | mock Stripe API; begin payment for checkout | session URL returned, payment record created | happy path |","-| resolves the correct payment provider | default config uses Stripe | resolved provider is StripePaymentProvider | happy path |","-| throws for unsupported payment provider | attempt to resolve unknown provider | exception thrown | validation |","-| creates a payment record with pending status | begin payment | payment row in DB with status=requires_action | happy path |","-| updates payment status on capture | process successful payment callback | payment status = captured | happy path |","+| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |","+| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |","+| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |","+| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |","+| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |"," "," ---"," ","+#### tests/Feature/Payments/BankTransferConfirmationTest.php","+","+| Test Name | Setup / Preconditions | Key Assertions | Category |","+|-----------|----------------------|----------------|----------|","+| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |","+| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |","+| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |","+| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |","+| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |","+| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |","+","+---","+"," #### tests/Feature/Customers/CustomerAccountTest.php"," "," | Test Name | Setup / Preconditions | Key Assertions | Category |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"9cc1c37d-7aaa-4c82-a01f-64eb0e46bad2"}
{"parentUuid":"0a14181e-e75b-4501-9751-561d1aa52053","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01NSFJCUUiagCWVY8BbhQuDQ","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the test summary table and service registry."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1551,"cache_read_input_tokens":149481,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1551},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X28W47doJeokqZEGLa","type":"assistant","uuid":"8d99052e-3965-49c4-ae89-b7ae180b95ce","timestamp":"2026-02-11T15:28:30.546Z"}
{"parentUuid":"8d99052e-3965-49c4-ae89-b7ae180b95ce","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01NSFJCUUiagCWVY8BbhQuDQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012HrTNAF5cqJarKYH5HgB5R","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |","new_string":"| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1551,"cache_read_input_tokens":149481,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1551},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X28W47doJeokqZEGLa","type":"assistant","uuid":"2fcbf042-066e-431c-968a-e5526956896e","timestamp":"2026-02-11T15:28:32.817Z"}
{"parentUuid":"2fcbf042-066e-431c-968a-e5526956896e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_012HrTNAF5cqJarKYH5HgB5R","toolUseID":"toolu_012HrTNAF5cqJarKYH5HgB5R","timestamp":"2026-02-11T15:28:32.824Z","uuid":"9472353a-352a-4de5-98d2-17e1913015dd"}
{"parentUuid":"2fcbf042-066e-431c-968a-e5526956896e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012HrTNAF5cqJarKYH5HgB5R","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"3a347f28-14d2-446c-b62f-cd15bc09909e","timestamp":"2026-02-11T15:28:32.823Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |","newString":"| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/StripeWebhookTest` | Stripe webhook feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":1895,"oldLines":8,"newStart":1895,"newLines":9,"lines":[" | `Orders/OrderCreationTest` | Order creation feature tests |"," | `Orders/RefundTest` | Refund feature tests |"," | `Orders/FulfillmentTest` | Fulfillment feature tests |","-| `Payments/StripeWebhookTest` | Stripe webhook feature tests |","+| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |"," | `Payments/PaymentServiceTest` | Payment service feature tests |","+| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |"," | `Customers/CustomerAccountTest` | Customer account feature tests |"," | `Customers/AddressManagementTest` | Address management feature tests |"," | `Admin/DashboardTest` | Admin dashboard feature tests |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"2fcbf042-066e-431c-968a-e5526956896e"}
{"parentUuid":"3a347f28-14d2-446c-b62f-cd15bc09909e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01NSFJCUUiagCWVY8BbhQuDQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FqdDhe5Qrd5aM1S8QSsrnX","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |","new_string":"| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1551,"cache_read_input_tokens":149481,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1551},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X28W47doJeokqZEGLa","type":"assistant","uuid":"cacb7a48-e493-4da7-b34f-528f5caf6710","timestamp":"2026-02-11T15:28:34.467Z"}
{"parentUuid":"cacb7a48-e493-4da7-b34f-528f5caf6710","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01FqdDhe5Qrd5aM1S8QSsrnX","toolUseID":"toolu_01FqdDhe5Qrd5aM1S8QSsrnX","timestamp":"2026-02-11T15:28:34.474Z","uuid":"7fe19383-4bf0-4e9a-8914-ef000112ad78"}
{"parentUuid":"cacb7a48-e493-4da7-b34f-528f5caf6710","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FqdDhe5Qrd5aM1S8QSsrnX","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"0bd0ef25-30df-4e2c-8540-21f164f89a82","timestamp":"2026-02-11T15:28:34.474Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |","newString":"| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |\n| `Payments\\PaypalPaymentProvider` | Stub implementation |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":1973,"oldLines":8,"newStart":1973,"newLines":7,"lines":[" | `SearchService` | FTS5 index management, query execution, autocomplete |"," | `AnalyticsService` | Event tracking, daily aggregation |"," | `WebhookService` | Event dispatch, HMAC signing, delivery management |","-| `Payments\\StripePaymentProvider` | Stripe Checkout Sessions, webhook handling |","-| `Payments\\PaypalPaymentProvider` | Stub implementation |","+| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"cacb7a48-e493-4da7-b34f-528f5caf6710"}
{"parentUuid":"0bd0ef25-30df-4e2c-8540-21f164f89a82","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01LdJnwaeztBsFLkuUjmZ3dT","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me also add the fulfillment guard tests to the FulfillmentTest section."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":558,"cache_read_input_tokens":151032,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":558},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X2fNgPPWuvM5LuP6fz","type":"assistant","uuid":"cb82095f-bbd8-47fb-8862-9e14e88d7ff6","timestamp":"2026-02-11T15:28:37.674Z"}
{"parentUuid":"cb82095f-bbd8-47fb-8862-9e14e88d7ff6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01LdJnwaeztBsFLkuUjmZ3dT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014GGnP7psy7yzPS9UR6xRjm","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1410,"limit":30}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":558,"cache_read_input_tokens":151032,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":558},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X2fNgPPWuvM5LuP6fz","type":"assistant","uuid":"5f5af988-1136-47ee-ae13-56fd09018324","timestamp":"2026-02-11T15:28:38.547Z"}
{"parentUuid":"5f5af988-1136-47ee-ae13-56fd09018324","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_014GGnP7psy7yzPS9UR6xRjm","toolUseID":"toolu_014GGnP7psy7yzPS9UR6xRjm","timestamp":"2026-02-11T15:28:38.553Z","uuid":"0f8924fc-acfc-4534-a67c-af8532d09ec3"}
{"parentUuid":"5f5af988-1136-47ee-ae13-56fd09018324","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014GGnP7psy7yzPS9UR6xRjm","type":"tool_result","content":"  1410→| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n  1411→| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n  1412→| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n  1413→| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n  1414→| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n  1415→\n  1416→---\n  1417→\n  1418→#### tests/Feature/Orders/FulfillmentTest.php\n  1419→\n  1420→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1421→|-----------|----------------------|----------------|----------|\n  1422→| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n  1423→| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n  1424→| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n  1425→| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n  1426→| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n  1427→| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n  1428→| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n  1429→| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n  1430→\n  1431→---\n  1432→\n  1433→#### tests/Feature/Payments/MockPaymentProviderTest.php\n  1434→\n  1435→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1436→|-----------|----------------------|----------------|----------|\n  1437→| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n  1438→| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n  1439→| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |"}]},"uuid":"5182a45f-d51b-4656-b64b-868437eb7c53","timestamp":"2026-02-11T15:28:38.552Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |","numLines":30,"startLine":1410,"totalLines":2059}},"sourceToolAssistantUUID":"5f5af988-1136-47ee-ae13-56fd09018324"}
{"parentUuid":"5182a45f-d51b-4656-b64b-868437eb7c53","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_0149jdBnAvmaCXGDjWz5vc4X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CaoTx2Yzwv2nbkVNyb2LSa","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |","new_string":"| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |\n| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |\n| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |\n| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":895,"cache_read_input_tokens":151590,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":895},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X2xoRibQFb89DiizQM","type":"assistant","uuid":"6dc276cd-2bf7-4fa8-8466-4209b7ce135f","timestamp":"2026-02-11T15:28:45.509Z"}
{"parentUuid":"6dc276cd-2bf7-4fa8-8466-4209b7ce135f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01CaoTx2Yzwv2nbkVNyb2LSa","toolUseID":"toolu_01CaoTx2Yzwv2nbkVNyb2LSa","timestamp":"2026-02-11T15:28:45.515Z","uuid":"54e3bc04-f31b-44b6-9999-87677e0c6e3a"}
{"parentUuid":"6dc276cd-2bf7-4fa8-8466-4209b7ce135f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CaoTx2Yzwv2nbkVNyb2LSa","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"b73bdd05-956a-41ed-bece-3052ebebb0be","timestamp":"2026-02-11T15:28:45.514Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |","newString":"| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |\n| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |\n| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |\n| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":1426,"oldLines":6,"newStart":1426,"newLines":10,"lines":[" | transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |"," | transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |"," | prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |","+| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |","+| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |","+| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |","+| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |"," | only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |"," "," ---"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"6dc276cd-2bf7-4fa8-8466-4209b7ce135f"}
{"parentUuid":"b73bdd05-956a-41ed-bece-3052ebebb0be","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Jcf4ixpBo87k58p3sde7AD","type":"message","role":"assistant","content":[{"type":"text","text":"Let me do a final check for any remaining Stripe/PayPal references across all spec files."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":454,"cache_read_input_tokens":152485,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":454},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3UZ5r1UBo3TMQpmvD","type":"assistant","uuid":"a6fed86b-f700-46ab-975a-bf4120db950a","timestamp":"2026-02-11T15:28:48.683Z"}
{"parentUuid":"a6fed86b-f700-46ab-975a-bf4120db950a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Jcf4ixpBo87k58p3sde7AD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013E7GG7inTS9XaHnW8vZGHy","name":"Grep","input":{"pattern":"(?i)\\bstripe\\b","path":"/Users/fabianwesner/Herd/shop/specs","output_mode":"content","-n":true}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":454,"cache_read_input_tokens":152485,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":454},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3UZ5r1UBo3TMQpmvD","type":"assistant","uuid":"9fc7544c-cfd8-4a2d-8760-e2a9c3ad5358","timestamp":"2026-02-11T15:28:49.882Z"}
{"parentUuid":"9fc7544c-cfd8-4a2d-8760-e2a9c3ad5358","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_013E7GG7inTS9XaHnW8vZGHy","toolUseID":"toolu_013E7GG7inTS9XaHnW8vZGHy","timestamp":"2026-02-11T15:28:50.157Z","uuid":"7e4b9b9f-e80d-41c1-80fd-30772eb9629b"}
{"parentUuid":"9fc7544c-cfd8-4a2d-8760-e2a9c3ad5358","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013E7GG7inTS9XaHnW8vZGHy","type":"tool_result","content":"specs/06-AUTH-AND-SECURITY.md:617:| Webhook routes | `api`, `throttle:webhooks` | Incoming webhooks (Stripe, PayPal) - no auth |\nspecs/06-AUTH-AND-SECURITY.md:935:### 7.1 Stripe Webhook Verification\nspecs/06-AUTH-AND-SECURITY.md:942:    B --> C[Get Stripe-Signature header]\nspecs/06-AUTH-AND-SECURITY.md:944:    D --> E{Verify signature using Stripe SDK}\nspecs/06-AUTH-AND-SECURITY.md:961:2. Retrieve the `Stripe-Signature` header from the incoming request.\nspecs/06-AUTH-AND-SECURITY.md:962:3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.\nspecs/06-AUTH-AND-SECURITY.md:963:4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.\nspecs/06-AUTH-AND-SECURITY.md:1100:| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |\nspecs/06-AUTH-AND-SECURITY.md:1167:| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |\nspecs/09-IMPLEMENTATION-ROADMAP.md: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. |\nspecs/claude-spec-check.md:261:- **Payment processing** (Stripe + PayPal stub, webhook handling)\nspecs/03-ADMIN-UI.md:1630:|  Provider: [Stripe Tax v]                                        |\nspecs/03-ADMIN-UI.md:1647:**Provider config:** Shown when mode = \"provider\". Provider select (Stripe Tax / None) and API key password input.\nspecs/05-BUSINESS-LOGIC.md:744:- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\nspecs/05-BUSINESS-LOGIC.md:796:### 8.4 Stripe Tax Provider (Stub)\nspecs/05-BUSINESS-LOGIC.md:800:- Sends line items and shipping address to Stripe Tax API.\nspecs/05-BUSINESS-LOGIC.md:806:| `\"block\"` | Block checkout progression if Stripe Tax API errors |\nspecs/05-BUSINESS-LOGIC.md: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.\nspecs/05-BUSINESS-LOGIC.md:1647:| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\nspecs/04-STOREFRONT-UI.md:1821:    Q --> R[Stripe/PayPal Redirect]\nspecs/08-PLAYWRIGHT-E2E-PLAN.md:68:| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\nspecs/08-PLAYWRIGHT-E2E-PLAN.md:3168:5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\nspecs/claude-spec-check-02.md:182:Spec 02 has inbound webhook endpoints (Stripe, PayPal) but no outbound webhook subscription CRUD API. The admin UI route `/admin/developers` (spec 02 line 124) mentions \"webhook management\" and spec 06 references the `manage-developers` gate covering webhooks. However, there are no REST API endpoints for creating, listing, updating, or deleting webhook subscriptions."}]},"uuid":"b9a8a05e-1554-4a8b-a431-1cc4c0bcd823","timestamp":"2026-02-11T15:28:50.157Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"specs/06-AUTH-AND-SECURITY.md:617:| Webhook routes | `api`, `throttle:webhooks` | Incoming webhooks (Stripe, PayPal) - no auth |\nspecs/06-AUTH-AND-SECURITY.md:935:### 7.1 Stripe Webhook Verification\nspecs/06-AUTH-AND-SECURITY.md:942:    B --> C[Get Stripe-Signature header]\nspecs/06-AUTH-AND-SECURITY.md:944:    D --> E{Verify signature using Stripe SDK}\nspecs/06-AUTH-AND-SECURITY.md:961:2. Retrieve the `Stripe-Signature` header from the incoming request.\nspecs/06-AUTH-AND-SECURITY.md:962:3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.\nspecs/06-AUTH-AND-SECURITY.md:963:4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.\nspecs/06-AUTH-AND-SECURITY.md:1100:| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |\nspecs/06-AUTH-AND-SECURITY.md:1167:| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |\nspecs/09-IMPLEMENTATION-ROADMAP.md: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. |\nspecs/claude-spec-check.md:261:- **Payment processing** (Stripe + PayPal stub, webhook handling)\nspecs/03-ADMIN-UI.md:1630:|  Provider: [Stripe Tax v]                                        |\nspecs/03-ADMIN-UI.md:1647:**Provider config:** Shown when mode = \"provider\". Provider select (Stripe Tax / None) and API key password input.\nspecs/05-BUSINESS-LOGIC.md:744:- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\nspecs/05-BUSINESS-LOGIC.md:796:### 8.4 Stripe Tax Provider (Stub)\nspecs/05-BUSINESS-LOGIC.md:800:- Sends line items and shipping address to Stripe Tax API.\nspecs/05-BUSINESS-LOGIC.md:806:| `\"block\"` | Block checkout progression if Stripe Tax API errors |\nspecs/05-BUSINESS-LOGIC.md: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.\nspecs/05-BUSINESS-LOGIC.md:1647:| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\nspecs/04-STOREFRONT-UI.md:1821:    Q --> R[Stripe/PayPal Redirect]\nspecs/08-PLAYWRIGHT-E2E-PLAN.md:68:| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\nspecs/08-PLAYWRIGHT-E2E-PLAN.md:3168:5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\nspecs/claude-spec-check-02.md:182:Spec 02 has inbound webhook endpoints (Stripe, PayPal) but no outbound webhook subscription CRUD API. The admin UI route `/admin/developers` (spec 02 line 124) mentions \"webhook management\" and spec 06 references the `manage-developers` gate covering webhooks. However, there are no REST API endpoints for creating, listing, updating, or deleting webhook subscriptions.","numLines":23},"sourceToolAssistantUUID":"9fc7544c-cfd8-4a2d-8760-e2a9c3ad5358"}
{"parentUuid":"b9a8a05e-1554-4a8b-a431-1cc4c0bcd823","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BieRKpmYMVCTBvrtq3dzxq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"There are some remaining Stripe references:\n\n1. **specs/06-AUTH-AND-SECURITY.md** - Stripe webhook verification, webhook routes - this file wasn't part of the original 9 spec files I was asked to update. The summary says there are 9 spec files. Let me check what files are in the specs folder. Actually, looking at the conversation summary, the user asked to adjust \"the specs\" and the 9 implementation roadmap mentions the spec files numbered 01-09. The 06-AUTH-AND-SECURITY.md file has Stripe webhook verification sections that should also be updated. But the claude-spec-check files are probably review documents.\n\n\n2. **specs/03-ADMIN-UI.md:1630** and **specs/05-BUSINESS-LOGIC.md** both reference Stripe Tax, which is about tax calculation rather than payment processing, so those can stay as-is.\n3. **specs/04-STOREFRONT-UI.md:1821** has a \"Stripe/PayPal Redirect\" reference that needs to be updated, and there's another reference in the Playwright tests that I need to check.\n4. Looking at the full scope of the 9 spec files, I should also update **specs/06-AUTH-AND-SECURITY.md** along with the remaining fixes in 04, 06, and 08.\n\nNow I'm going through the specific lines that need updating: the Stripe/PayPal redirect section in 04, the webhook verification part in 06, and the payment testing notes in 08.","signature":"ErUQCkYICxgCKkAeQEGvpN+vy5Pq+c6Z1LRMSEEH2f1lu/pbBjkNdeZ7j+OoYG2SCA0UZhBNZq/6ZooDVk0UDR0DfkI78kPPT8ELEgxPrwlY9vwHv/94u3EaDL+xZ3wWt9i7NQUfbCIwmV73MPW9a2RI7lAdugwj7J7a04VTUHXwSn2AeSNwLVP8iARaIOsMjg9FBjz27XTGKpwPS/V3r8pUPkaWMu0yS6l53hY4n5iXVEhfiUpyHpMYf/tdEUsNlzsAiKMPPAB87wyQnsxX7t9uKjMaXGmUL2u5fw72K9Htbbt0en/1OG2gez116oJoATSRNNZ08XMMEj+sc4BaEgrk7cAwowKrDuIhYZ4Yc1ZgOM9xjBpARvGsVg+aCTGocSr89xGr4w2NOviqZ+DpLGNDdv/Bp7JweetdXxI1lJWjh/AUkkkzgRf9KkaiXAUG1q6J83ehK2KglRBTdfy5Dxv1Dre5yr0adJ5xswBhwnIsroAn1o6hx9ZS6F98K4Slj+Suo1jOyMgj+9f6juFx8nrZtFkbpboz99EWuUdzfpDnycWQ6y25KR6rieILsYe1mLVo3hFjb+ugS5tOKjQsmTopnkZAqFtF7dTi5/3Am3A5afUA/I9gJI7st8ISWgGgWx5xMyqO07Sz8fr03NU8GHzuxXJFoidjfsFfdWJwT+1XCCGZIq4Ntv87J36ZeW2dloUvQkTwaGWvx8jKaQy3qwAIotBCZ3lgGON0HVxIwUwx9NCUZmMWCTBn5bYOxg7+IJXTxc3MBVu6bhTbzhmvoLW2DeeQwqstJi4XdKD1U6ifqbj03DxVx5HeFZixzgF1X76mmsZqDNBP7hFtkfLpFzQDconOaN7xkBVZnHjdkZEIq2vb84JjBlmQ3OEmWaGviF/0UyFZz4UF2DRUed3l72osy5t04ImZtoch5+JewW6LF+A4H6kbq9tbQ+lXe1Rs4j0AP7O0eNDqddY6wX0UlMcsAQYw5BXFe21bkEX6F7i7MYTn1fP8BPMuHBGat517+TI0zF2B65LFETlAQVfMdVS8Wra0f+D+SaCVQbjdaEQ9lpU8AyzSCebVIXUMIddYV7BQbFfKBAvBewtDuh68/+GpUkIkPUThyMC1MnYYiqa9ZQxOKdzNpriT8D3PWvmMlNRHRsWGFMymm91sbXyebWZQ6zZrEO5CXK0alfsPmd/KhADUAJlEk3SIPxV6U9pNu1l23alicpyUAwjttM8YH3XhpHxJaN48nxfZNangaIT1Azub81jLn81GDhs+KpDBMGQjBKm/pP4JPmZYwhA94KfQbIW0nCE7toU0Ht5O2++lm/0UL/pZTJjmALxKc7rkAtGpyrzR7SqMRpss5WMaa4wT7oWfjCzDPBYCrFZi4Ag2D51ZMJBz0Brr6GejqOeSuxdAG9iqOTs2UdYS29Mtcq4qOlOGdOczqNCPNdCEFO5M2BiLbBfrdndHrVvwoVwV3trVUnADz+69NHt86Fb84BiPGWJRk7AQbSTykFvblVDTd3jnDhlTMBQjDTVPPPd2FNXWp89WXrXH9twxsBoitEnrF4HxPwZw+Xoamhst48RXoQ2L3wCuFqbRHeKCAmB7nhttjFcvx0GNqFadTgIqCeIQbdaZtnbmlTqq9GLYSSEst9vK6zGN/ayRBunvI0OCmGKMTMDT2uQd/raT0tbJ+HAROA5iBGl3A6cx0rbQez664B4XHmmUOzmzws7o2MhxB+fJMhrmoXUgK1/ugPZr5iscvcZq50GqNXUuUxp3s+LCgCX49La+yEBzwizpsFeIHXNwGukn+NFXLEB67/MmzHH/NHUqkeNmaZ1r5We13IfDl00SOV+bTzGIe+WXEoXxKrgxfeamgalBXQNfUrRLDMb11Ku5Qtu/OxFPForznlq+rInUM+Xug69DQ4KH4ccnXrr9O7vAMErr5JjMiYxdnFsSxsYasVA+0DgkgFS5jduEyDBm6XQPT6G/3LCKCtR0q3Xfam2tpqlqu/35SBo3IUAPrs7dIXYeWjD9cFqfmw7Y92CYApOvDN4daFNxoz3joTyk0nbpBqhYdw9oKN7cQyuK5AzFRzU964wBBegL/6t9Ips7sJnbsBiV0t3CWgNNdJ99r41n0Qld9RdEzL01GaVhgWpmdaJfMDBfjiv4qfK0ZZRC6G7+gd0MEm07fak0KsuqEBZtvJj4K9OleXE9g1wkWCt3LRaZ+OTavwjzv8tn9Zh74vD2f6fdD2xsTlWuv5tseW4iRjmj7jhjsB1++JWKlXAUlra+6a1CN/GVbvzz/hBqK6BW0RodVI/vrxRCM8B4SWOBeI8p97/iyrtBizJ1nL6utoXjyedHOny/Jn9Vk2VlrPs7k81qhHjn2LORjFFykpnFqVz7h1zhgBKEKFwH6ZtOLdkUOastY8uAOsaNrYPcP8ZJrFaB4oAp7MLA0d3NvwuZZ/Iwq2jsifI3VcPLVkJzo5/od4eueJuwy+dOwtMoCXc5/FfutcXg+MagCi/v9tYRq2xHHqDdatgC2vOghwL8qpEHlZXEC4IsoxTm/Bto1WS1YdqdpwtToDmwZuFSasGQzyOTd3NYPKrmaXtsqSuMq9jKR3+NE4ar5MtPR4MkhTH9dEywiJ0sVdfrmkANyWEO+WWbJ/mFDHlumWNtLrbJrpOAPcw7tMrLcDVz9mBCTdZ4at6tX8xcRuRVy55zO37OBAg2S4fYkKnyOhxY3dzluCzv+KfOwrcKPlYMPU0W/fcETKYVOpHVWacn7zKJN/stDIUMS8qSD09/l7hCtmLyyYdYc2slbZYG0eUAUsRkJpI4hhgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1469,"cache_read_input_tokens":152939,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1469},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3ovQsFRCMC2m3v3aL","type":"assistant","uuid":"62c918c5-8ada-4891-b56e-414ee2c65aac","timestamp":"2026-02-11T15:29:04.033Z"}
{"parentUuid":"62c918c5-8ada-4891-b56e-414ee2c65aac","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BieRKpmYMVCTBvrtq3dzxq","type":"message","role":"assistant","content":[{"type":"text","text":"There are still some Stripe/PayPal references in other spec files. Let me fix the remaining ones."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1469,"cache_read_input_tokens":152939,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1469},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3ovQsFRCMC2m3v3aL","type":"assistant","uuid":"08abb613-141e-4344-99b6-0d9747aabcbd","timestamp":"2026-02-11T15:29:04.176Z"}
{"parentUuid":"08abb613-141e-4344-99b6-0d9747aabcbd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BieRKpmYMVCTBvrtq3dzxq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PVzSKvPjRDuZWV3F92BmoL","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","offset":1815,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1469,"cache_read_input_tokens":152939,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1469},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3ovQsFRCMC2m3v3aL","type":"assistant","uuid":"4c06afb6-b78f-4b47-8384-3ad38362f0db","timestamp":"2026-02-11T15:29:04.295Z"}
{"parentUuid":"4c06afb6-b78f-4b47-8384-3ad38362f0db","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01PVzSKvPjRDuZWV3F92BmoL","toolUseID":"toolu_01PVzSKvPjRDuZWV3F92BmoL","timestamp":"2026-02-11T15:29:04.299Z","uuid":"c2e2652e-334f-4ba7-a281-7b83837c2899"}
{"parentUuid":"4c06afb6-b78f-4b47-8384-3ad38362f0db","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PVzSKvPjRDuZWV3F92BmoL","type":"tool_result","content":"  1815→    L --> J\n  1816→    K -->|Checkout| M[Checkout Page]\n  1817→    M --> N[Step 1: Contact Info]\n  1818→    N --> O[Step 2: Shipping Address]\n  1819→    O --> P[Step 3: Shipping Method]\n  1820→    P --> Q[Step 4: Payment]\n  1821→    Q --> R[Stripe/PayPal Redirect]\n  1822→    R --> S[Order Confirmation]\n  1823→    S --> T{Next?}\n  1824→    T -->|Continue Shopping| A\n  1825→    T -->|View Order| U[Account Order Detail]\n  1826→```\n  1827→\n  1828→### Account Management Flow\n  1829→"}]},"uuid":"2575ea99-6313-46e2-b885-3c9cb3a0a6ef","timestamp":"2026-02-11T15:29:04.298Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"    L --> J\n    K -->|Checkout| M[Checkout Page]\n    M --> N[Step 1: Contact Info]\n    N --> O[Step 2: Shipping Address]\n    O --> P[Step 3: Shipping Method]\n    P --> Q[Step 4: Payment]\n    Q --> R[Stripe/PayPal Redirect]\n    R --> S[Order Confirmation]\n    S --> T{Next?}\n    T -->|Continue Shopping| A\n    T -->|View Order| U[Account Order Detail]\n```\n\n### Account Management Flow\n","numLines":15,"startLine":1815,"totalLines":1925}},"sourceToolAssistantUUID":"4c06afb6-b78f-4b47-8384-3ad38362f0db"}
{"parentUuid":"2575ea99-6313-46e2-b885-3c9cb3a0a6ef","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BieRKpmYMVCTBvrtq3dzxq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JGq31scibgvHoqcBpJQTZB","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","offset":610,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1469,"cache_read_input_tokens":152939,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1469},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3ovQsFRCMC2m3v3aL","type":"assistant","uuid":"64299d98-ed85-4445-9320-a1a3a62bf1da","timestamp":"2026-02-11T15:29:04.807Z"}
{"parentUuid":"64299d98-ed85-4445-9320-a1a3a62bf1da","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01JGq31scibgvHoqcBpJQTZB","toolUseID":"toolu_01JGq31scibgvHoqcBpJQTZB","timestamp":"2026-02-11T15:29:04.809Z","uuid":"9c5fc345-e75f-4cdd-955e-cf7fab06f73b"}
{"parentUuid":"64299d98-ed85-4445-9320-a1a3a62bf1da","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JGq31scibgvHoqcBpJQTZB","type":"tool_result","content":"   610→|-------------|-----------------|-------------|\n   611→| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n   612→| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n   613→| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n   614→| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n   615→| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n   616→| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n   617→| Webhook routes | `api`, `throttle:webhooks` | Incoming webhooks (Stripe, PayPal) - no auth |\n   618→\n   619→---\n   620→\n   621→### 3.3 Custom Middleware Specifications\n   622→\n   623→#### ResolveStore\n   624→"}]},"uuid":"facad5cc-5dfe-4526-a7a9-dd93d4055c2e","timestamp":"2026-02-11T15:29:04.809Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"|-------------|-----------------|-------------|\n| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n| Webhook routes | `api`, `throttle:webhooks` | Incoming webhooks (Stripe, PayPal) - no auth |\n\n---\n\n### 3.3 Custom Middleware Specifications\n\n#### ResolveStore\n","numLines":15,"startLine":610,"totalLines":1170}},"sourceToolAssistantUUID":"64299d98-ed85-4445-9320-a1a3a62bf1da"}
{"parentUuid":"facad5cc-5dfe-4526-a7a9-dd93d4055c2e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BieRKpmYMVCTBvrtq3dzxq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EEYTsba1qqjQWpBT7KpinM","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","offset":925,"limit":50}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1469,"cache_read_input_tokens":152939,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1469},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3ovQsFRCMC2m3v3aL","type":"assistant","uuid":"0459086e-c4da-45bd-b5ac-fc158db1ee82","timestamp":"2026-02-11T15:29:05.649Z"}
{"parentUuid":"0459086e-c4da-45bd-b5ac-fc158db1ee82","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01EEYTsba1qqjQWpBT7KpinM","toolUseID":"toolu_01EEYTsba1qqjQWpBT7KpinM","timestamp":"2026-02-11T15:29:05.651Z","uuid":"3d74d14c-d465-47d9-97aa-feda9046b9ab"}
{"parentUuid":"0459086e-c4da-45bd-b5ac-fc158db1ee82","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EEYTsba1qqjQWpBT7KpinM","type":"tool_result","content":"   925→| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |\n   926→| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |\n   927→| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |\n   928→| `max_age` | `0` | No preflight caching (conservative default). |\n   929→| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |\n   930→\n   931→---\n   932→\n   933→## 7. Webhook Signature Verification\n   934→\n   935→### 7.1 Stripe Webhook Verification\n   936→\n   937→Controller: `App\\Http\\Controllers\\Webhooks\\StripeWebhookController`\n   938→\n   939→```mermaid\n   940→flowchart TD\n   941→    A[Receive POST request] --> B[Read raw request body]\n   942→    B --> C[Get Stripe-Signature header]\n   943→    C --> D[Get STRIPE_WEBHOOK_SECRET from config]\n   944→    D --> E{Verify signature using Stripe SDK}\n   945→    E -- Invalid --> F[Return 400 Bad Request]\n   946→    E -- Valid --> G[Parse event type]\n   947→    G --> H{Route to handler}\n   948→    H --> I[checkout.session.completed]\n   949→    H --> J[payment_intent.succeeded]\n   950→    H --> K[charge.refunded]\n   951→    H --> L[Unknown event - ignore]\n   952→    I --> M[Return 200 OK]\n   953→    J --> M\n   954→    K --> M\n   955→    L --> M\n   956→```\n   957→\n   958→**Requirements:**\n   959→\n   960→1. Read the raw request body (not parsed JSON) because signature verification depends on the exact byte sequence.\n   961→2. Retrieve the `Stripe-Signature` header from the incoming request.\n   962→3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.\n   963→4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.\n   964→5. On signature verification failure: return 400 immediately.\n   965→6. On success: dispatch to the appropriate handler based on event type, then return 200.\n   966→7. Handled event types: `checkout.session.completed`, `payment_intent.succeeded`, `charge.refunded`. Unknown events are acknowledged (200) but ignored.\n   967→\n   968→---\n   969→\n   970→### 7.2 PayPal Webhook Verification\n   971→\n   972→Controller: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`\n   973→\n   974→```mermaid"}]},"uuid":"0e2e27b9-1d73-4c4d-9eb5-fa3bd395e9e7","timestamp":"2026-02-11T15:29:05.651Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |\n| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |\n| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |\n| `max_age` | `0` | No preflight caching (conservative default). |\n| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |\n\n---\n\n## 7. Webhook Signature Verification\n\n### 7.1 Stripe Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\StripeWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw request body]\n    B --> C[Get Stripe-Signature header]\n    C --> D[Get STRIPE_WEBHOOK_SECRET from config]\n    D --> E{Verify signature using Stripe SDK}\n    E -- Invalid --> F[Return 400 Bad Request]\n    E -- Valid --> G[Parse event type]\n    G --> H{Route to handler}\n    H --> I[checkout.session.completed]\n    H --> J[payment_intent.succeeded]\n    H --> K[charge.refunded]\n    H --> L[Unknown event - ignore]\n    I --> M[Return 200 OK]\n    J --> M\n    K --> M\n    L --> M\n```\n\n**Requirements:**\n\n1. Read the raw request body (not parsed JSON) because signature verification depends on the exact byte sequence.\n2. Retrieve the `Stripe-Signature` header from the incoming request.\n3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.\n4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.\n5. On signature verification failure: return 400 immediately.\n6. On success: dispatch to the appropriate handler based on event type, then return 200.\n7. Handled event types: `checkout.session.completed`, `payment_intent.succeeded`, `charge.refunded`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.2 PayPal Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`\n\n```mermaid","numLines":50,"startLine":925,"totalLines":1170}},"sourceToolAssistantUUID":"0459086e-c4da-45bd-b5ac-fc158db1ee82"}
{"parentUuid":"0e2e27b9-1d73-4c4d-9eb5-fa3bd395e9e7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BieRKpmYMVCTBvrtq3dzxq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BEjTjR1FeYSty5xw5g6gsx","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":60,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1469,"cache_read_input_tokens":152939,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1469},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3ovQsFRCMC2m3v3aL","type":"assistant","uuid":"4e5ecc42-d4b8-4c83-987b-c62c55bfe3ae","timestamp":"2026-02-11T15:29:06.290Z"}
{"parentUuid":"4e5ecc42-d4b8-4c83-987b-c62c55bfe3ae","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01BEjTjR1FeYSty5xw5g6gsx","toolUseID":"toolu_01BEjTjR1FeYSty5xw5g6gsx","timestamp":"2026-02-11T15:29:06.293Z","uuid":"a08f98d9-427f-4180-8f50-07ab81567ebb"}
{"parentUuid":"4e5ecc42-d4b8-4c83-987b-c62c55bfe3ae","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BEjTjR1FeYSty5xw5g6gsx","type":"tool_result","content":"    60→The `.env.testing` file must contain the following keys:\n    61→\n    62→| Key | Value | Purpose |\n    63→|-----|-------|---------|\n    64→| `APP_ENV` | `testing` | Laravel environment |\n    65→| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n    66→| `DB_CONNECTION` | `sqlite` | Database driver |\n    67→| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n    68→| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n    69→| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n    70→| `MAIL_MAILER` | `array` | Trap emails in memory |\n    71→| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n    72→\n    73→### Prerequisites Before Running\n    74→"}]},"uuid":"990de638-eec4-4907-b01c-903094d8ab00","timestamp":"2026-02-11T15:29:06.293Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"The `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n","numLines":15,"startLine":60,"totalLines":3175}},"sourceToolAssistantUUID":"4e5ecc42-d4b8-4c83-987b-c62c55bfe3ae"}
{"parentUuid":"990de638-eec4-4907-b01c-903094d8ab00","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BieRKpmYMVCTBvrtq3dzxq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Gy7nH79D1PezTbdYNKqA5G","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":3160,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1469,"cache_read_input_tokens":152939,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1469},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X3ovQsFRCMC2m3v3aL","type":"assistant","uuid":"05fcbff7-0a9a-4bbc-97cd-488fe8839c37","timestamp":"2026-02-11T15:29:07.117Z"}
{"parentUuid":"05fcbff7-0a9a-4bbc-97cd-488fe8839c37","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01Gy7nH79D1PezTbdYNKqA5G","toolUseID":"toolu_01Gy7nH79D1PezTbdYNKqA5G","timestamp":"2026-02-11T15:29:07.120Z","uuid":"503be651-2dd2-4b7d-84d7-d2a9ed2a1b02"}
{"parentUuid":"05fcbff7-0a9a-4bbc-97cd-488fe8839c37","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Gy7nH79D1PezTbdYNKqA5G","type":"tool_result","content":"  3160→1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n  3161→\n  3162→2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n  3163→\n  3164→3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n  3165→\n  3166→4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n  3167→\n  3168→5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n  3169→\n  3170→6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n  3171→\n  3172→7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n  3173→\n  3174→8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup."}]},"uuid":"26ccb049-6b74-47a1-8451-4afd136bcc2f","timestamp":"2026-02-11T15:29:07.119Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.","numLines":15,"startLine":3160,"totalLines":3175}},"sourceToolAssistantUUID":"05fcbff7-0a9a-4bbc-97cd-488fe8839c37"}
{"parentUuid":"26ccb049-6b74-47a1-8451-4afd136bcc2f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KxhGd4ZZqx4MQN1oyn2cWk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Xj1Yit4ayHKkipvQhWaZ5V","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","old_string":"    P --> Q[Step 4: Payment]\n    Q --> R[Stripe/PayPal Redirect]\n    R --> S[Order Confirmation]","new_string":"    P --> Q[Step 4: Payment Method & Pay]\n    Q --> S[Order Confirmation]"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3233,"cache_read_input_tokens":154408,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3233},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X54xovLoiiAbditgQY","type":"assistant","uuid":"a868ac4f-bc5b-4433-a3ea-63923b6a6ca5","timestamp":"2026-02-11T15:29:11.525Z"}
{"parentUuid":"a868ac4f-bc5b-4433-a3ea-63923b6a6ca5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Xj1Yit4ayHKkipvQhWaZ5V","toolUseID":"toolu_01Xj1Yit4ayHKkipvQhWaZ5V","timestamp":"2026-02-11T15:29:11.531Z","uuid":"1681941e-a04e-4dc8-8e25-208b4702de89"}
{"parentUuid":"a868ac4f-bc5b-4433-a3ea-63923b6a6ca5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Xj1Yit4ayHKkipvQhWaZ5V","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md has been updated successfully."}]},"uuid":"66457b7b-42b1-42ce-acfc-a51bb8e8ab85","timestamp":"2026-02-11T15:29:11.531Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","oldString":"    P --> Q[Step 4: Payment]\n    Q --> R[Stripe/PayPal Redirect]\n    R --> S[Order Confirmation]","newString":"    P --> Q[Step 4: Payment Method & Pay]\n    Q --> S[Order Confirmation]","originalFile":"# 04 - Storefront UI/UX Specification\n\n> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n| Templating | Blade templates with reusable storefront-prefixed components |\n| Accessibility | WCAG 2.2 AA compliance throughout |\n| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n\n---\n\n## Currency Formatting\n\nAll monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n\n| Setting | Value |\n|---------|-------|\n| Decimal separator | `.` (period) |\n| Thousands separator | `,` (comma) |\n| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n| Decimal places | Always 2 |\n| Free amounts | Display as `0.00 EUR` |\n| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n\n**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n\n**Examples:**\n- 2499 cents -> `24.99 EUR`\n- 0 cents -> `0.00 EUR`\n- 149900 cents -> `1,499.00 EUR`\n\n---\n\n## 1. Theme System Architecture\n\n### 1.1 View Organization\n\nThe storefront views are organized into the following categories:\n\n| Category | Purpose | Views |\n|---|---|---|\n| Layouts | Master page shell | app (master layout) |\n| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n| Checkout | Purchase flow | checkout index, order confirmation |\n| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n| Errors | Error states | 404, 503 |\n| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n\n### 1.2 Layout Hierarchy\n\nAll storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n\n### 1.3 Theme Settings\n\nTheme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n\n- Announcement bar visibility, text, link, and background color\n- Sticky header toggle\n- Home page section order and visibility\n- Color palette overrides (primary, secondary, accent)\n- Typography scale\n- Footer content and social links\n- Dark mode behavior (system preference, toggle, or forced)\n\n### 1.4 Section-Based Architecture\n\nEach page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n\n| Section | Description | Used On |\n|---|---|---|\n| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n| Featured Collections | Grid of collection cards | Home |\n| Featured Products | Grid of product cards (lazy-loaded) | Home |\n| Newsletter | Email signup form | Home, Footer |\n| Rich Text | Customizable content block | Home, Pages |\n\n### 1.5 Blade Components\n\nAll reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n\n---\n\n## 2. Base Layout\n\n### 2.1 Document Head\n\nThe document head includes:\n\n| Element | Description |\n|---|---|\n| Character encoding | UTF-8 |\n| Viewport | Width = device width, initial scale 1 |\n| Meta description | Page-specific, yielded from child templates |\n| Title | Page-specific title appended with the app name |\n| Stylesheets | Compiled CSS via Vite |\n| Scripts | Compiled JS via Vite |\n| Livewire styles | Injected by Livewire |\n| Language attribute | Set on the HTML element from application locale |\n| Scroll behavior | Smooth scrolling enabled globally |\n\n### 2.2 Skip Link\n\nA visually hidden \"Skip to main content\" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.\n\n**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.\n\n### 2.3 Announcement Bar\n\nOptional, controlled by theme settings. Renders a full-width bar above the header.\n\n| Attribute | Description |\n|---|---|\n| Position | Full-width, above the header |\n| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |\n| Text | Centered, small size, configurable content |\n| Link | Optional, underlined, opens in same tab |\n| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |\n| Height | Auto height with vertical padding |\n\n### 2.4 Header\n\nThe header serves as the primary navigation banner.\n\n**Desktop Layout (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |\n| Main Navigation | Center | Horizontal nav with links sourced from the \"main-menu\" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |\n| Search Icon | Right group | Button that opens the search modal |\n| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |\n| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |\n\n**Mobile Layout (below large breakpoint):**\n\n```\n+--------------------------------------------------------------+\n| [Hamburger]           [Logo]                            [Cart]|\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Hamburger menu | Left | Toggle button for mobile navigation drawer |\n| Logo | Center | Same as desktop but horizontally centered |\n| Cart icon | Right | Same as desktop |\n\n**Sticky Header (optional):**\n\nWhen enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.\n\n**Mobile Navigation Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Animation | Slides in from the left side |\n| Height | Full viewport height |\n| Backdrop | Semi-transparent dark overlay behind the drawer |\n| Close button | X icon at the top-right of the drawer |\n| Navigation items | Vertical stack with comfortable tap targets |\n| Submenus | Collapsible accordion pattern with chevron icons |\n| Account link | Positioned at the bottom of the navigation list |\n| Focus behavior | Focus is trapped within the drawer while open |\n| ARIA | Labeled as \"Mobile navigation\" |\n\n### 2.5 Main Content Area\n\nThe main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.\n\n### 2.6 Footer\n\nThe footer is a content-info landmark.\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n|                                                               |\n| [Social Icons]                                                |\n| [Copyright] [Payment Icons]                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n| Store info column | Store name, address (if configured), contact email |\n| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n| Background | Light gray (dark: very dark gray), top border separator |\n\n### 2.7 Cart Drawer (Livewire)\n\nThe cart drawer is always present in the layout as a global Livewire component. It listens for the \"cart-updated\" browser event to refresh and open. See Section 6 for full specification.\n\n### 2.8 Dark Mode\n\nDark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:\n\n| Mode | Implementation |\n|---|---|\n| System preference (default) | Follows OS-level dark mode preference automatically |\n| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |\n| Forced light/dark | Applied at server render time on the HTML element |\n\n**Color Token Patterns:**\n\n| Token | Light Mode | Dark Mode |\n|---|---|---|\n| Page background | White | Near-black |\n| Card/section background | Light gray | Very dark gray |\n| Heading text | Near-black | White |\n| Body text | Medium gray | Light gray |\n| Borders | Light gray | Medium-dark gray |\n| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |\n\n---\n\n## 3. Home Page (GET /)\n\nThe home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:\n\n1. Hero Banner\n2. Featured Collections\n3. Featured Products\n4. Newsletter Signup\n5. Rich Text\n\n### 3.1 Hero Banner Section\n\n**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.\n\n**Desktop (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n|                                                               |\n|  [Background Image / Gradient]                                |\n|                                                               |\n|       [Heading - very large, bold]                            |\n|       [Subheading - medium, regular weight]                   |\n|       [CTA Button - primary, large]                           |\n|                                                               |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |\n| Content | Centered vertically and horizontally, white text |\n| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |\n| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |\n| CTA | Styled as primary button, large. Links to a configured collection or product |\n| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |\n| Padding | Generous vertical and horizontal padding |\n\n**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.\n\n**Accessibility:**\n\n- If background is an image, it is decorative (the content provides meaning)\n- CTA button has descriptive text (e.g., \"Shop the summer collection\")\n\n### 3.2 Featured Collections Section\n\n**Layout:** Section with heading and grid of collection cards.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, large, bold, centered, with generous bottom margin |\n| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |\n| Count | 2 to 4 collections, configurable in theme settings |\n\n**Collection Card:**\n\n```\n+---------------------------+\n|  [Collection Image]       |\n|  3:4 aspect ratio         |\n|  cover-fit, rounded       |\n|                           |\n|  [Title overlay at bottom]|\n|  [Shop now ->]            |\n+---------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |\n| Title | White text overlaid at bottom of image, semi-bold, medium-large size |\n| \"Shop now\" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |\n| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |\n\n**Accessibility:**\n\n- Each card link has the collection title as accessible name\n- Images have alt text matching the collection title\n\n### 3.3 Featured Products Section\n\n**Livewire Component:** FeaturedProducts\n\nThis section uses Livewire for lazy loading to improve initial page load.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, same styling as featured collections heading |\n| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |\n| Products | 4 to 8, configurable. Sourced from a configured collection or \"best sellers\" logic |\n| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |\n\nEach product uses the product-card component (see Section 16).\n\n### 3.4 Newsletter Signup Section\n\n**Livewire Component:** NewsletterSignup\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n|  [Heading: \"Stay in the loop\"]                                |\n|  [Subtext: \"Subscribe for exclusive offers and updates.\"]     |\n|  [Email input] [Subscribe button]                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Light gray (dark mode: darker gray), generous vertical padding |\n| Content | Centered, constrained to a comfortable width |\n| Heading | H2, medium-large, bold |\n| Subtext | Muted color, small top margin |\n| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |\n| Input | Email type, placeholder \"Enter your email\", required |\n| Button | Primary style, \"Subscribe\" text |\n| Success state | Replaces form with \"Thanks for subscribing!\" message |\n| Error state | Inline error below input in red, small text |\n| Loading state | Button text changes to \"Subscribing...\" during submission |\n\n**Accessibility:**\n\n- Label associated with email input (visually hidden if using placeholder)\n- Success/error messages in a polite live region for screen readers\n\n### 3.5 Rich Text Section\n\nSimple section rendering customizable content from theme settings.\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained to comfortable reading width, centered, vertical padding |\n| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |\n| Dark mode | Inverted prose styling |\n\n---\n\n## 4. Collection Page (GET /collections/{handle})\n\n**Livewire Component:** CollectionPage\n\n### 4.1 Collection Header\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > Collections > {Collection Title} |\n| Title | H1, large, bold |\n| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |\n| Spacing | Generous vertical padding |\n\n### 4.2 Toolbar\n\nHorizontal bar between the header and the product grid.\n\n**Desktop:**\n\n```\n+--------------------------------------------------------------+\n| [Filter toggle button]          [X products] [Sort dropdown]  |\n+--------------------------------------------------------------+\n```\n\n**Mobile:**\n\n```\n+--------------------------------------------------------------+\n| [Filter button]                           [Sort dropdown]     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Product count | Small muted text, e.g., \"24 products\" |\n| Sort dropdown | Select element with live Livewire binding |\n\n**Sort Options:**\n\n| Option | Description |\n|---|---|\n| Featured | Default sort order |\n| Price: Low to High | Ascending by price |\n| Price: High to Low | Descending by price |\n| Newest | Descending by creation date |\n| Best Selling | Descending by sales count |\n\n### 4.3 Filter Sidebar\n\n**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.\n**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by \"Filter\" button with filter icon.\n\n**Filter Groups:**\n\nEach filter group is a collapsible section with a heading and toggle chevron.\n\n| Filter Group | Type | Binding | Description |\n|---|---|---|---|\n| Availability | Single checkbox | Live | \"In stock\" toggle |\n| Price Range | Two number inputs | Live with 500ms debounce | \"Min\" and \"Max\" inputs with currency symbol prefix, displayed side by side |\n| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |\n| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |\n\n**Clear All:**\n\n- Link at the top of the filter sidebar: \"Clear all filters\"\n- Only visible when at least one filter is active\n- Small, blue, interactive text\n\n**Active Filter Pills:**\n\n- Displayed above the product grid (below toolbar) when filters are active\n- Each pill: rounded tag with filter value text and a remove (X) button\n- \"Clear all\" link at the end of the pills row\n\n**Mobile Filter Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Position | Full-height overlay from left side |\n| Header | \"Filters\" title + close button (X) |\n| Content | Same filter groups as desktop, stacked vertically |\n| Footer | Sticky \"Apply\" button at bottom that closes the drawer |\n| Clear | \"Clear all\" link in header area |\n| Focus | Trapped within drawer while open |\n\n### 4.4 Product Grid\n\n| Attribute | Description |\n|---|---|\n| Layout | Fills remaining width next to the sidebar |\n| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |\n| Loading state | Subtle opacity reduction on the grid while results update |\n\n### 4.5 Product Card (Component)\n\nThe product card is a reusable component used on collection pages, search results, and featured products sections.\n\n```\n+---------------------------+\n|  [Product Image]          |\n|  1:1 aspect ratio         |\n|  cover-fit, rounded       |\n|  (hover: show 2nd image)  |\n|                           |\n|  [Sale badge]  [Sold out] |\n+---------------------------+\n|  [Product Title]          |\n|  [Price]  [Compare price] |\n|  [Quick Add / Options]    |\n+---------------------------+\n```\n\n**Image Behavior:**\n\n| Attribute | Description |\n|---|---|\n| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |\n| Primary image | Cover-fit, fills the container |\n| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |\n| Fallback | If no image, show a centered shopping bag placeholder icon |\n| Loading | Lazy-loaded |\n\n**Badges:**\n\n| Badge | Condition | Position |\n|---|---|---|\n| \"Sale\" | compare_at_amount > price_amount | Top-left of image, stacked vertically |\n| \"Sold out\" | All variants out of stock with deny policy | Top-left of image, stacked vertically |\n\n**Text Content:**\n\n| Element | Description |\n|---|---|\n| Title | H3 (or configurable heading level), small semi-bold text. Truncated to 2 lines |\n| Price | Current price in small semi-bold text. Compare-at price shown with strikethrough if applicable |\n| Quick Add | Single variant: \"Add to cart\" button. Multiple variants: \"Choose options\" link text. Hidden by default on desktop (shown on hover), always visible on mobile |\n\n**Accessibility:**\n\n- Card link wraps the entire card; title serves as the accessible name\n- Badges have screen reader labels (e.g., \"On sale\")\n- Price changes announced via parent live region when filtering changes results\n\n### 4.6 Pagination\n\n| Attribute | Description |\n|---|---|\n| Style | Numbered pagination with previous/next arrow buttons |\n| Current page | Highlighted with primary color background and white text |\n| Other pages | Muted text, hover background |\n| Disabled | Reduced opacity and not-allowed cursor on previous (at first page) or next (at last page) |\n| Truncation | Ellipsis for large page counts |\n| Mobile | Simplified: previous/next only with current page indicator |\n\n### 4.7 Empty State\n\nWhen no products match the current filters:\n\n```\n+--------------------------------------------------------------+\n|  [Search icon - large, muted]                                 |\n|  \"No products found\"                                          |\n|  \"Try adjusting your filters or browse our full collection.\"  |\n|  [Clear filters button]                                       |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Icon | Large, muted search icon |\n| Heading | Medium, semi-bold |\n| Subtext | Muted, descriptive guidance |\n| Button | Secondary style, \"Clear filters\" |\n| Position | Centered in the grid area with generous vertical padding |\n\n---\n\n## 5. Product Page (GET /products/{handle})\n\n**Livewire Component:** ProductPage\n\n### 5.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column, 50/50 split. Left column (images) is sticky while the right column (product info) scrolls.\n\n**Mobile:** Single column, stacked. Images first, then product info.\n\n```\nDesktop:\n+-------------------------------+-------------------------------+\n|  [Image Gallery]              |  [Product Info]               |\n|  (sticky while scrolling)     |  (scrollable)                 |\n+-------------------------------+-------------------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Image Gallery - horizontal scroll]                          |\n+--------------------------------------------------------------+\n|  [Product Info - full width]                                  |\n+--------------------------------------------------------------+\n```\n\nContainer constrained to standard max-width with horizontal padding and vertical spacing.\n\n### 5.2 Image Gallery (Left Column)\n\n**Desktop:**\n\n```\n+-------------------------------+\n|                               |\n|  [Main Image - large]         |\n|  1:1 aspect ratio, rounded    |\n|                               |\n+-------------------------------+\n| [Thumb] [Thumb] [Thumb] ...  |\n+-------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Main image | Square aspect ratio, large rounded corners, cover-fit. Gray placeholder background |\n| Zoom on hover | Optional (theme setting): image scales to 150% and follows cursor position |\n| Thumbnail strip | Horizontal row with overflow scroll, small gap between thumbnails |\n| Each thumbnail | 64x64px, rounded, cover-fit, pointer cursor. Active thumbnail has a colored border |\n| Variant images | When a variant is selected that has specific images, those images are shown |\n\n**Mobile:**\n\n| Attribute | Description |\n|---|---|\n| Gallery | Horizontal scrollable row with snap-scroll behavior |\n| Each image | Full-width, square aspect ratio, rounded, cover-fit |\n| Scroll indicators | Row of dots below the gallery. Active dot is dark (light in dark mode), inactive dots are gray |\n\n**Accessibility:**\n\n| Requirement | Description |\n|---|---|\n| Alt text | Main image has descriptive alt text from product media data |\n| Thumbnails | Rendered as buttons with labels \"View image X of Y\" and current state indicator |\n| Region | Gallery wrapped in a labeled region (\"Product images\") |\n\n### 5.3 Product Info (Right Column)\n\n#### Breadcrumbs\n\nHome > {Primary Collection} > {Product Title}. Small, muted text with separator characters.\n\n#### Product Title\n\nH1, large, bold.\n\n#### Price Display\n\n| Element | Description |\n|---|---|\n| Current price | Large, bold |\n| Compare-at price | Medium, muted, strikethrough (shown when set and higher than current price) |\n| Sale badge | Inline badge next to the price |\n| Currency | Formatted based on the store's default currency |\n| Live updates | Price container is a live region, so price changes when variant changes are announced to screen readers |\n\n#### Variant Selector\n\nFor each product option (e.g., Size, Color), render a selector group.\n\n**Radio button style (for options with 6 or fewer values):**\n\n```\nSize\n[S] [M] [L] [XL] [XXL]\n```\n\n| State | Visual Description |\n|---|---|\n| Available | Bordered pill shape, pointer cursor, border darkens on hover |\n| Selected | Primary color border with subtle background tint and focus ring |\n| Unavailable | Muted border, muted text, strikethrough, reduced opacity, not-allowed cursor |\n\n**Select dropdown style:** Used when the option has more than 6 values.\n\n**Color swatches (when option name is \"Color\"):**\n\n| Attribute | Description |\n|---|---|\n| Shape | Circular, 32x32px |\n| Color | Determined by value name mapped to a color lookup or inline style |\n| Selected state | Ring with offset around the swatch |\n| Tooltip | Shows color name on hover |\n\n**Livewire behavior:**\n\n- Selected options are a key-value map (e.g., Size = M, Color = Red)\n- Selected variant is computed by matching all selected options to the variant option values\n- On change: updates price, stock messaging, available images, and add-to-cart availability\n\n#### Stock Messaging\n\nDisplayed below the variant selector.\n\n| Condition | Message | Visual Style |\n|---|---|---|\n| In stock, quantity > 10 | \"In stock\" | Green text with check icon |\n| In stock, quantity <= 10 | \"Only X left in stock\" | Amber text with warning icon |\n| Out of stock, deny policy | \"Out of stock\" | Red text with X icon |\n| Out of stock, continue policy | \"Available on backorder\" | Blue text with info icon |\n\nSmall text, icon + text inline. Wrapped in a live region for screen reader announcements on variant change.\n\n#### Quantity Selector\n\nUses the quantity-selector component.\n\n```\n[-] [3] [+]\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Inline flex, bordered, rounded |\n| Decrease button | \"-\" icon, 40x40px. Disabled at minimum (1) |\n| Input | Centered number input, 56px wide, 40px tall. Min = 1, Max = available stock (deny policy) or unlimited (continue policy) |\n| Increase button | \"+\" icon, same size. Disabled at maximum |\n| Disabled state | Reduced opacity, not-allowed cursor |\n| Labels | Input labeled \"Quantity\", buttons labeled \"Decrease quantity\" / \"Increase quantity\" |\n\n#### Add to Cart Button\n\n| Attribute | Description |\n|---|---|\n| Width | Full width |\n| Style | Primary button: blue background, white text, semi-bold, generous padding, rounded |\n| Text | \"Add to cart\" |\n| Loading state | Text changes to \"Adding...\" with spinner icon. Button disabled during loading |\n| Sold out state | Gray background, muted text, not-allowed cursor. Text: \"Sold out\" |\n| Spacing | Generous top margin |\n\n**Livewire action (addToCart):**\n\n1. Validates variant is selected and in stock\n2. Adds to cart (creates cart if none exists in session)\n3. Dispatches \"cart-updated\" browser event\n4. Cart drawer opens automatically\n\n#### Product Description\n\n- Separated from above content by a horizontal rule with generous vertical spacing\n- Rendered HTML styled with prose/typography styling\n\n#### Tags\n\n- Displayed below the description (if tags exist)\n- Horizontal wrapping row with gap\n- Each tag: rounded pill, gray background, small text\n\n### 5.4 Accessibility Summary\n\n| Requirement | Implementation |\n|---|---|\n| Variant radio buttons | Properly labeled with labels and grouped with fieldset/legend |\n| Live regions | Price display, stock messaging, add-to-cart feedback |\n| Image alt text | All images have meaningful alt text |\n| Keyboard | Tab through thumbnails, variant options, quantity controls, and add-to-cart |\n| Focus indicators | Visible ring styles on all interactive elements |\n\n---\n\n## 6. Cart Drawer\n\n**Livewire Component:** CartDrawer\n\n### 6.1 Trigger and Animation\n\n| Attribute | Description |\n|---|---|\n| Trigger | \"cart-updated\" browser event (from add-to-cart actions), or clicking the cart icon in the header |\n| Animation | Slides in from the right side of the viewport. Transforms from fully off-screen to visible |\n| Backdrop | Fixed full-screen semi-transparent dark overlay. Click to close |\n| Panel | Fixed, right-aligned, full height, full width on small screens or 384px on larger screens. White background (dark mode: near-black), shadow |\n| Body | Scroll locked when drawer is open |\n\n### 6.2 Drawer Header\n\n```\n+--------------------------------------+\n| Your Cart (3)                    [X] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Heading | H2, semi-bold, medium-large text |\n| Item count | In parentheses after heading |\n| Close button | X icon, labeled \"Close cart\" |\n| Layout | Flex row, space-between, with horizontal padding and bottom border |\n\n### 6.3 Cart Line Items\n\nScrollable list area occupying the space between header and totals.\n\nEach line item:\n\n```\n+--------------------------------------+\n| [Thumb]  Title                       |\n|          Variant: Size M / Blue      |\n|          [-] [2] [+]       $49.98    |\n|                             [Remove] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Thumbnail | 64x64px, rounded, cover-fit, non-shrinking |\n| Title | Small semi-bold text, truncated to 1 line |\n| Variant info | Extra-small muted text showing selected options |\n| Quantity stepper | Compact version of quantity-selector (smaller buttons, 32x32px) |\n| Line total | Small semi-bold text |\n| Remove button | Trash icon, muted color, turns red on hover. Labeled \"Remove {item title} from cart\" |\n| Separation | Bottom border between items, vertical padding per item |\n| Loading | Opacity reduction on the line item during quantity update |\n\n### 6.4 Discount Code Section\n\nBelow the line items list, above totals.\n\n**No discount applied:**\n\n```\n+--------------------------------------+\n| [Discount code input] [Apply]        |\n+--------------------------------------+\n```\n\n**Discount applied:**\n\n```\n+--------------------------------------+\n| SUMMER20 (-15%)              [Remove]|\n+--------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Input | Small text, placeholder \"Discount code\" |\n| Apply button | Secondary style, small |\n| Success state | Green text showing the code and discount type, with a remove button |\n| Error state | Small red text below, e.g., \"Invalid discount code\" or \"This code has expired\" |\n| Loading state | Button text changes to \"Applying...\" |\n\n### 6.5 Totals Section\n\n```\n+--------------------------------------+\n| Subtotal                     $149.97 |\n| Discount (SUMMER20)          -$22.50 |\n| ------------------------------------ |\n| Estimated total              $127.47 |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Position | Sticky at the bottom of the drawer, with top border |\n| Each line | Flex row, justified space-between, small text |\n| Discount line | Only shown when discount applied, green text |\n| Total | Slightly larger text, semi-bold |\n| Note | \"Shipping and taxes calculated at checkout\" in extra-small muted text below totals |\n\n### 6.6 Action Buttons\n\n| Button | Description |\n|---|---|\n| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |\n| Continue shopping | Centered text link below checkout button. Small muted text, hover to darker. Closes the drawer |\n\n### 6.7 Empty Cart State\n\n```\n+--------------------------------------+\n|                                      |\n|  [Shopping bag icon - large]         |\n|  \"Your cart is empty\"                |\n|  [Continue shopping button]          |\n|                                      |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Centered vertically and horizontally in the drawer content area |\n| Icon | Large, very muted shopping bag icon |\n| Text | Muted, with top margin |\n| Button | Secondary style, closes the drawer |\n\n### 6.8 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Role | Dialog with modal behavior, labeled \"Shopping cart\" |\n| Focus trap | Tab/Shift+Tab cycles through drawer elements only |\n| On open | Focus moves to the close button or first interactive element |\n| On close | Focus returns to the element that triggered the drawer (cart icon) |\n| Escape key | Closes the drawer |\n| Feedback | All quantity changes and removals announced via polite live region |\n\n---\n\n## 7. Full Cart Page (GET /cart)\n\n**Livewire Component:** CartPage\n\nSame functionality as the cart drawer but in a full-page layout.\n\n### 7.1 Desktop Layout\n\n```\n+--------------------------------------------------------------+\n|  Your Cart                                                    |\n+--------------------------------------------------------------+\n|  Image | Product      | Price   | Quantity | Total   |       |\n|  ------|------------- |---------|----------|---------|-------|\n|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n|        | Size: M      |         |          |         |       |\n|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n|        | Color: Blue  |         |          |         |       |\n+--------------------------------------------------------------+\n|                                            Subtotal: $84.97   |\n|                              [Discount code] [Apply]          |\n|                                            Discount: -$12.75  |\n|                                            Total:    $72.22   |\n|                                                               |\n|                                            [Checkout button]  |\n|                                            [Continue shopping]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n| Title | H1, large bold, generous bottom margin |\n| Desktop layout | Proper table with header row and body |\n| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n\n### 7.2 Totals and Actions\n\n- Right-aligned on desktop, full-width on mobile\n- Same discount code input/apply functionality as drawer\n- Same totals breakdown\n- \"Checkout\" button: primary, aligned right\n- \"Continue shopping\" link: below checkout button\n\n### 7.3 Empty State\n\nSame as drawer empty state but centered in the page content area with more vertical padding.\n\n---\n\n## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment Method & Pay\n\nAll payment is handled onsite via the Mock PSP. No external redirects, no iframes, no third-party scripts.\n\n**Payment method selection (radio buttons):**\n\n```\n+--------------------------------------------------------------+\n| Select a payment method                                       |\n|                                                               |\n| (o) Credit Card                                               |\n| ( ) PayPal                                                    |\n| ( ) Bank Transfer                                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Layout | Radio button list, each option in a bordered card (same styling as shipping method radios) |\n| Default | First option (Credit Card) is pre-selected |\n| Live binding | On selection, the payment form below updates to show the relevant fields |\n\n**Credit Card form (shown when Credit Card is selected):**\n\n```\n+--------------------------------------------------------------+\n| Card number     [4242 4242 4242 4242        ]                |\n| Cardholder name [John Doe                   ]                |\n| Expiry          [12/28     ]  CVC [123    ]                  |\n+--------------------------------------------------------------+\n| [Pay now - 29.98 EUR]                                        |\n+--------------------------------------------------------------+\n```\n\n| Field | Type | Required | Validation |\n|---|---|---|---|\n| Card number | Text (digits, auto-formatted with spaces every 4 digits) | Yes | 16 digits |\n| Cardholder name | Text | Yes | Max 255 |\n| Expiry | Text (MM/YY format) | Yes | Valid future date |\n| CVC | Text (3-4 digits) | Yes | 3-4 digits |\n\n**PayPal form (shown when PayPal is selected):**\n\n```\n+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Pay with PayPal - {total}\".\n\n**Bank Transfer form (shown when Bank Transfer is selected):**\n\n```\n+--------------------------------------------------------------+\n| After placing your order, you will receive bank transfer      |\n| instructions. Your order will be held for 7 days while we     |\n| await your payment.                                           |\n|                                                               |\n| [Place order - 29.98 EUR]                                     |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Place order - {total}\" (not \"Pay now\", since payment is deferred).\n\n| Element | Description |\n|---|---|\n| Pay/Place button | Primary, full-width, large text. Loading state: \"Processing...\" with spinner. Calls `POST /checkouts/{id}/pay` with the selected method and card details (if credit card). |\n| Error handling | On payment decline (magic card number), display an inline error callout: \"Payment declined: {reason}\". The customer can correct details and retry. |\n| Success (card/paypal) | On successful instant capture, redirect to confirmation page. |\n| Success (bank_transfer) | On successful order placement, redirect to confirmation page (which shows bank transfer instructions). |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.\n\n```\n+--------------------------------------------------------------+\n|  [Checkmark icon - green circle]                              |\n|  \"Thank you for your order!\"                                  |\n|  Order #1042                                                  |\n|  \"We've sent a confirmation to you@email.com\"                 |\n+--------------------------------------------------------------+\n|  Order Summary                                                |\n|  [Items list with thumbnails]                                 |\n+--------------------------------------------------------------+\n|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Credit Card ending in 4242      |\n|  123 Main St               |  (or: PayPal / Bank Transfer)    |\n|  New York, NY 10001        |                                  |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  [Continue shopping button]  [View order - if logged in]      |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained to a comfortable width, centered, generous vertical padding |\n| Success icon | Large green checkmark in a circle, centered |\n| Heading | H1, large bold, centered, generous top margin |\n| Order number | Medium muted text, centered |\n| Email notice | Small muted text, centered |\n\n### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Payment method display | Shows \"Credit Card ending in {last4}\", \"PayPal\", or \"Bank Transfer\" depending on `order.payment_method` |\n| Totals | Right-aligned summary |\n\n#### Bank Transfer Instructions (shown only when `payment_method = bank_transfer`)\n\nA prominent callout (info variant) displayed between the order details and the totals:\n\n```\n+--------------------------------------------------------------+\n|  [info icon] Bank Transfer Instructions                       |\n|                                                               |\n|  Please transfer the total amount to the following account:   |\n|                                                               |\n|  Bank:     Mock Bank AG                                       |\n|  IBAN:     DE89 3704 0044 0532 0130 00                        |\n|  BIC:      COBADEFFXXX                                        |\n|  Amount:   29.98 EUR                                          |\n|  Reference: #1042                                             |\n|                                                               |\n|  Please complete your transfer within 7 days. Your order will |\n|  be processed once payment is confirmed by our team.          |\n+--------------------------------------------------------------+\n```\n\nThe reference field uses the order number so the admin can match incoming transfers.\n\n### 9.3 Actions\n\n| Button | Description |\n|---|---|\n| Continue shopping | Primary, links to home page |\n| View order | Link to account order detail page (logged-in customers only) |\n| Layout | Centered row with gap |\n\n---\n\n## 10. Customer Account Pages\n\nAll account pages require authentication. Unauthenticated access redirects to the login page.\n\n### 10.1 Login (GET /account/login)\n\n**Livewire Component:** Login\n\n```\n+--------------------------------------------------------------+\n|  Log in to your account                                       |\n|                                                               |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Forgot password? link]                                      |\n|  [Log in button]                                              |\n|                                                               |\n|  Don't have an account? [Create one]                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, autocomplete for current password |\n| Forgot password | Small blue link, right-aligned |\n| Submit button | Primary, full-width, \"Log in\" |\n| Error messages | Inline below fields in small red text |\n| General error | Invalid credentials shown in a callout/alert above the form |\n| Register link | \"Create one\" link to registration page |\n\n### 10.2 Register (GET /account/register)\n\n**Livewire Component:** Register\n\n```\n+--------------------------------------------------------------+\n|  Create an account                                            |\n|                                                               |\n|  [Name input]                                                 |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Confirm password input]                                     |\n|  [ ] Subscribe to marketing emails                            |\n|  [Create account button]                                      |\n|                                                               |\n|  Already have an account? [Log in]                            |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Name | Text type, required, autocomplete for name |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, minimum 8 characters, autocomplete for new password |\n| Confirm password | Password type, required, autocomplete for new password |\n| Marketing checkbox | Checkbox with label \"Subscribe to marketing emails\" |\n| Submit button | Primary, full-width, \"Create account\" |\n| On success | Redirect to account dashboard |\n\n### 10.3 Account Dashboard (GET /account)\n\n```\n+--------------------------------------------------------------+\n|  Welcome back, Jane!                                          |\n+--------------------------------------------------------------+\n|  [Orders card]     [Addresses card]     [Log out card]        |\n+--------------------------------------------------------------+\n|  Recent Orders                                                |\n|  # | Date       | Status  | Total   | Action                 |\n|  --|------------|---------|---------|--------                 |\n|  1042 | Jan 15  | Paid    | $98.70  | View                   |\n|  1041 | Jan 10  | Shipped | $45.00  | View                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained max-width, centered, with padding |\n| Welcome heading | H1, medium-large bold |\n| Quick links | Responsive grid: 1 column on mobile, 3 on tablet+. Each card has border, rounded corners, padding, hover shadow |\n| Orders card | Order icon, \"Order history\", \"View all your orders\" |\n| Addresses card | Location icon, \"Addresses\", \"Manage your addresses\" |\n| Log out card | Logout icon, \"Log out\", submits POST logout form |\n| Recent orders | Table showing last 5 orders. Status shown as badge component |\n\n### 10.4 Order History (GET /account/orders)\n\n| Attribute | Description |\n|---|---|\n| Heading | H1, \"Order History\" |\n| Table columns | Order (link), Date, Status (badge), Total, Action (\"View\" link) |\n| Status badge colors | Pending: yellow. Paid: green. Fulfilled: blue. Cancelled: gray. Refunded: red |\n| Mobile | Card-based layout instead of table. Each card shows order number, date, status, and total |\n| Pagination | Standard pagination component |\n\n### 10.5 Order Detail (GET /account/orders/{orderNumber})\n\n```\n+--------------------------------------------------------------+\n|  Order #1042                      Status: [Paid] [Fulfilled] |\n|  Placed on January 15, 2026                                  |\n+--------------------------------------------------------------+\n|  Items                                                        |\n|  [Thumb] Product Title x2              $49.98                 |\n|          Size: M                                              |\n+--------------------------------------------------------------+\n|  Shipping Address    |  Billing Address   | Payment           |\n|  Jane Doe            |  Same as shipping  | Visa ****4242     |\n|  123 Main St         |                    |                   |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Discount        -$0.00                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  Fulfillment                                                  |\n|  Shipped via UPS - 1Z999AA10123456784                         |\n|  [Track shipment ->]                                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Account > Orders > #1042 |\n| Header | Order number and status badges |\n| Date | Small muted text |\n| Items | Table with thumbnail, title + variant info, quantity, line total |\n| Info grid | Three columns on desktop (shipping, billing, payment), stacked on mobile |\n| Totals | Summary breakdown |\n| Fulfillment | Only shown if fulfillments exist. Shows tracking company, number, and link to tracking URL (opens in new tab safely) |\n\n### 10.6 Address Book (GET /account/addresses)\n\n**Livewire Component:** AddressBook\n\n```\n+--------------------------------------------------------------+\n|  Your Addresses                          [+ Add new address]  |\n+--------------------------------------------------------------+\n|  [Default badge]              |                               |\n|  Jane Doe                     |  Jane Doe                     |\n|  123 Main St                  |  456 Oak Ave                  |\n|  New York, NY 10001           |  Chicago, IL 60601            |\n|  United States                |  United States                |\n|  [Edit] [Delete]              |  [Edit] [Delete] [Set default]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Grid | 1 column on mobile, 2 on tablet, 3 on desktop |\n| Each card | Bordered, rounded, with padding. Default address has a primary-colored border and \"Default\" badge |\n| Content | Formatted address lines |\n| Actions | Edit and Delete text buttons, with \"Set as default\" on non-default addresses |\n| Add new | Opens modal with address-form component |\n| Edit | Opens same modal pre-filled |\n| Delete | Confirmation dialog before deleting |\n\n---\n\n## 11. Search\n\n### 11.1 Search Modal\n\n**Livewire Component:** SearchModal\n\nTriggered by clicking the search icon in the header.\n\n```\n+--------------------------------------------------------------+\n|  [Search input with icon]                           [X close] |\n+--------------------------------------------------------------+\n|  Products                                                     |\n|  [Thumb] Product Title            $24.99                      |\n|  [Thumb] Product Title            $34.99                      |\n|                                                               |\n|  Collections                                                  |\n|  Summer Collection                                            |\n|  Winter Collection                                            |\n|                                                               |\n|  [View all X results ->]                                      |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Overlay | Full-screen semi-transparent dark backdrop |\n| Modal | Centered, moderate max-width, rounded corners, shadow |\n| Search input | Large text, search icon prefix, autofocus on open. Live binding with 300ms debounce |\n| Results | Appear below input as user types. Grouped by type: \"Products\" and \"Collections\" headings |\n| Product results | Thumbnail, title, price. Clickable to product page |\n| Collection results | Title only. Clickable to collection page |\n| Max results | 5 per category in the modal |\n| View all | Link at the bottom navigating to the full search results page |\n| Loading | Skeleton lines while fetching |\n| No results | \"No results for '{query}'\" message |\n| Keyboard | Escape closes, arrow keys navigate results, Enter selects highlighted result |\n| Focus | Trapped within the modal |\n| ARIA | Dialog role, modal, labeled \"Search\". Results list as listbox with option items |\n\n### 11.2 Search Results Page (GET /search?q=)\n\n**Livewire Component:** SearchResults\n\nSame layout as the collection page (Section 4) with the following differences:\n\n| Difference | Description |\n|---|---|\n| Heading | H1, \"X results for '{query}'\" or \"Search results for '{query}'\" |\n| Breadcrumbs | Home > Search results |\n| Filters | Same filter sidebar (availability, price, type, vendor) |\n| Sort | Same sort dropdown |\n| Product grid | Same product card component |\n| Pagination | Same pagination |\n| Empty state | \"No results found for '{query}'. Try a different search term.\" with a search input to try again |\n\n---\n\n## 12. Content Pages (GET /pages/{handle})\n\n### 12.1 Layout\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > {Page Title} |\n| Title | H1, large bold |\n| Body | Rendered HTML from body_html, styled with prose/typography styling, constrained to comfortable reading width (configurable to full width via theme settings) |\n| Container | Standard max-width, centered, with padding |\n\n---\n\n## 13. Error Pages\n\n### 13.1 404 Page\n\n```\n+--------------------------------------------------------------+\n|  [404 - large, bold, very muted]                              |\n|  \"Page not found\"                                             |\n|  \"The page you're looking for doesn't exist or has been       |\n|   moved.\"                                                     |\n|                                                               |\n|  [Search input]                                               |\n|  [Go to home page button]                                     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered vertically and horizontally |\n| 404 text | Very large, bold, extremely muted (barely visible as a background element) |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Search | Simple input + button, submits to search page |\n| Home link | Secondary button |\n\n### 13.2 503 / Maintenance Page\n\n```\n+--------------------------------------------------------------+\n|  [Store logo]                                                 |\n|  \"We'll be back soon\"                                         |\n|  \"We're currently performing maintenance. Please check back   |\n|   shortly.\"                                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered column |\n| Logo | Standard height, centered |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Style | Consistent with the store theme (colors, fonts) |\n\n---\n\n## 14. Responsive Breakpoints\n\nDefault breakpoints used across the storefront:\n\n| Breakpoint | Min-width | Typical Devices |\n|---|---|---|\n| Base (none) | 0px | Mobile phones (portrait) |\n| Small | 640px | Mobile phones (landscape), small tablets |\n| Medium | 768px | Tablets (portrait) |\n| Large | 1024px | Tablets (landscape), small desktops |\n| Extra-large | 1280px | Desktops |\n\n### Key Responsive Behaviors\n\n| Component | Mobile (base) | Tablet (medium) | Desktop (large+) |\n|---|---|---|---|\n| Navigation | Hamburger + drawer | Hamburger + drawer | Horizontal nav bar |\n| Product grid | 2 columns | 3 columns | 3-4 columns |\n| Product page | Stacked (images, then info) | Stacked | Side-by-side 50/50 |\n| Cart drawer | Full-screen overlay | 384px sidebar | 384px sidebar |\n| Checkout | Single column, summary collapsed | Single column | Two columns (form + summary) |\n| Footer | Stacked columns | 2-column grid | 3-4 column grid |\n| Collection filters | Drawer overlay | Drawer overlay | Persistent sidebar |\n| Account tables | Card-based layout | Card-based layout | Table layout |\n\n### Touch Considerations\n\n| Consideration | Requirement |\n|---|---|\n| Tap targets | Minimum 44x44px for all interactive elements on mobile |\n| Hover effects | Only applied on devices with hover capability |\n| Swipe gestures | Horizontal swipe on product images (mobile gallery), cart drawer close |\n\n---\n\n## 15. Accessibility Checklist\n\n### Global (All Pages)\n\n| Requirement | Description |\n|---|---|\n| Skip link | \"Skip to main content\" link, first element in body |\n| Landmark roles | Banner for header, main for content, contentinfo for footer, labeled nav elements |\n| Heading hierarchy | Single H1 per page, logical H2-H6 nesting |\n| Focus indicators | Visible ring on all interactive elements with offset |\n| Color contrast | Minimum 4.5:1 for normal text, 3:1 for large text (WCAG AA) |\n| Keyboard navigation | All interactive elements reachable via Tab, operable via Enter/Space |\n| Screen reader text | Visually hidden text for icon-only buttons (close, cart, search) |\n| Language | Lang attribute on HTML element |\n| Images | All images have alt attributes; decorative images marked appropriately |\n\n### Page-Specific Accessibility\n\n| Page | Requirements |\n|---|---|\n| Collection Page | Live region for product count/grid on filter change. Filter sidebar labeled as navigation. Sort dropdown has associated label |\n| Product Page | Variant selector uses fieldset/legend grouping. Price and stock in live regions. Image gallery thumbnails as buttons with current state |\n| Cart Drawer | Dialog role with modal behavior, labeled \"Shopping cart\". Focus trap. Quantity changes in live region |\n| Checkout | Form validation with describedby linking to errors, invalid state on fields. Focus management on step transitions. Shipping radios in fieldset/legend. Totals in live region |\n| Search Modal | Dialog role with modal behavior. Results as listbox with option items. Active descendant tracking for keyboard navigation |\n| Account Pages | All form inputs labeled. Tables use proper header markup with column scope. Status badges readable by text (not color-only) |\n\n### Keyboard Interactions\n\n| Component | Key | Action |\n|---|---|---|\n| Hamburger menu | Enter/Space | Toggle open/close |\n| Mobile nav drawer | Escape | Close |\n| Dropdown menus | Arrow Up/Down | Navigate items |\n| Dropdown menus | Escape | Close |\n| Cart drawer | Escape | Close |\n| Search modal | Escape | Close |\n| Search results | Arrow Up/Down | Navigate results |\n| Search results | Enter | Select highlighted result |\n| Variant radio buttons | Arrow Left/Right | Navigate options |\n| Quantity input | Arrow Up/Down | Increment/decrement |\n| Modals/dialogs | Tab/Shift+Tab | Cycle focus within (trap) |\n\n---\n\n## 16. Component Library\n\n### storefront-layout\n\nMaster layout wrapper.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| title | string | App name from config | Page title |\n| metaDescription | string | Empty | Meta description tag |\n\n### storefront-product-card\n\nRenders a single product card in grid contexts.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| product | Product model | Required | Product with variants, media, and inventory eager-loaded |\n| headingLevel | string | h3 | Heading tag for the product title |\n| showQuickAdd | boolean | true | Whether to show the quick add / choose options action |\n\n**Eager-load requirements:** product.variants.inventoryItem, product.media (sorted by position).\n\n### storefront-price\n\nDisplays a formatted price with optional compare-at price.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| amount | integer | Required | Price in minor units (cents) |\n| currency | string | USD | ISO 4217 currency code |\n| compareAtAmount | integer or null | null | Original price in minor units for sale display |\n\n**Rendering:** Formats amount using Laravel's Number currency helper. If compareAtAmount is set and greater than amount, shows compare-at price with strikethrough styling and a \"Sale\" badge.\n\n### storefront-badge\n\nRenders a styled badge/tag.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| text | string | Required | Badge text |\n| variant | string | default | Visual variant: sale, sold-out, new, default |\n\n**Variant Styles:**\n\n| Variant | Background | Text Color |\n|---|---|---|\n| sale | Light red (dark: dark red tint) | Red |\n| sold-out | Light gray (dark: dark gray) | Medium gray |\n| new | Light blue (dark: dark blue tint) | Blue |\n| default | Light gray (dark: dark gray) | Dark gray |\n\n**Base appearance:** Inline pill shape, rounded-full, extra-small medium-weight text with horizontal padding.\n\n### storefront-quantity-selector\n\nNumber input with increment/decrement buttons.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| value | integer | 1 | Current quantity |\n| min | integer | 1 | Minimum allowed value |\n| max | integer or null | null | Maximum allowed value (null = unlimited) |\n| wireModel | string | Required | Livewire model binding string |\n| compact | boolean | false | Smaller variant for cart drawer |\n\n### storefront-address-form\n\nRenders a full address form with all fields.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| address | array or null | null | Pre-filled address data |\n| prefix | string | Empty | Livewire model prefix (e.g., \"shipping\" for shipping.firstName) |\n\n### storefront-order-summary\n\nRenders the checkout order summary sidebar.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |\n| showDiscountInput | boolean | true | Whether to show the discount code input |\n\n### storefront-breadcrumbs\n\nRenders a breadcrumb navigation trail.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| items | array | Required | Array of objects with label and url. Last item has no URL (current page) |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Breadcrumb\"\n- Ordered list with separator characters between items\n- Current page (last item) has a \"current page\" indicator and distinct styling\n- Includes structured data (BreadcrumbList schema) for SEO\n\n### storefront-pagination\n\nWraps Laravel's pagination with custom styling.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| paginator | LengthAwarePaginator | Required | Laravel paginator instance |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Pagination\"\n- Previous/Next buttons with arrow icons\n- Numbered page buttons with current page highlighted\n- Ellipsis for truncated ranges\n- Mobile: previous/next only with \"Page X of Y\" text\n\n---\n\n## 17. Livewire Component Summary\n\n| Component | Location | Purpose |\n|---|---|---|\n| FeaturedProducts | Home page | Lazy-loaded featured products grid |\n| NewsletterSignup | Home page, footer | Email subscription form |\n| CollectionPage | Collection page | Filtering, sorting, pagination |\n| ProductPage | Product page | Variant selection, add to cart |\n| CartDrawer | Layout (global) | Slide-in cart with line management |\n| CartPage | Cart page | Full-page cart |\n| CheckoutPage | Checkout | Multi-step checkout flow |\n| SearchModal | Layout (global) | Autocomplete search overlay |\n| SearchResults | Search page | Full search results with filters |\n| Login | Login page | Authentication form |\n| Register | Register page | Registration form |\n| AddressBook | Addresses page | CRUD for saved addresses |\n\n---\n\n## 18. Browser Event Communication\n\nLivewire components communicate via dispatched browser events:\n\n| Event Name | Dispatched By | Listened By | Payload |\n|---|---|---|---|\n| cart-updated | ProductPage, CartDrawer, CartPage | CartDrawer (opens + refreshes), Header (badge count) | cartId, itemCount |\n| open-cart-drawer | Header cart icon click | CartDrawer | None |\n| close-cart-drawer | CartDrawer close actions | CartDrawer | None |\n| open-search-modal | Header search icon click | SearchModal | None |\n| close-search-modal | SearchModal close actions | SearchModal | None |\n| variant-changed | ProductPage (variant selector) | ProductPage (internal) | variantId, price, stock |\n\n---\n\n## 19. SEO and Structured Data\n\n### Meta Tags\n\nEach page type sets appropriate meta tags:\n\n| Page | Title Pattern | Description Source |\n|---|---|---|\n| Home | Store name | Theme settings |\n| Collection | \"{Collection title} - {Store name}\" | Collection description (stripped of markup) |\n| Product | \"{Product title} - {Store name}\" | Product description (stripped, truncated to 160 chars) |\n| Content page | \"{Page title} - {Store name}\" | Page body (stripped, truncated) |\n| Search | \"Search results - {Store name}\" | Static |\n\n### Open Graph Tags\n\nProduct pages include Open Graph tags for social sharing:\n\n| Tag | Value |\n|---|---|\n| og:title | Product title |\n| og:description | Meta description |\n| og:image | Primary product image URL |\n| og:type | product |\n| og:url | Current page URL |\n| product:price:amount | Formatted price (dollars) |\n| product:price:currency | Store currency code |\n\n### JSON-LD Structured Data\n\n| Schema Type | Used On |\n|---|---|\n| Product | Product pages |\n| BreadcrumbList | Breadcrumb navigation on all pages |\n| Organization | Footer (store info) |\n\n---\n\n## 20. User Flow Diagrams\n\n### Browse to Purchase Flow\n\n```mermaid\nflowchart TD\n    A[Home Page] --> B{Browse}\n    B --> C[Collection Page]\n    B --> D[Search Modal]\n    D --> E[Search Results Page]\n    C --> F[Product Page]\n    E --> F\n    F --> G{Select Variant}\n    G --> H[Set Quantity]\n    H --> I[Add to Cart]\n    I --> J[Cart Drawer Opens]\n    J --> K{Continue?}\n    K -->|Continue Shopping| B\n    K -->|Apply Discount| L[Enter Discount Code]\n    L --> J\n    K -->|Checkout| M[Checkout Page]\n    M --> N[Step 1: Contact Info]\n    N --> O[Step 2: Shipping Address]\n    O --> P[Step 3: Shipping Method]\n    P --> Q[Step 4: Payment]\n    Q --> R[Stripe/PayPal Redirect]\n    R --> S[Order Confirmation]\n    S --> T{Next?}\n    T -->|Continue Shopping| A\n    T -->|View Order| U[Account Order Detail]\n```\n\n### Account Management Flow\n\n```mermaid\nflowchart TD\n    A[Login Page] --> B{Has Account?}\n    B -->|No| C[Register Page]\n    C --> D[Account Dashboard]\n    B -->|Yes| D\n    D --> E{Action}\n    E --> F[Order History]\n    F --> G[Order Detail]\n    G --> H[Track Shipment]\n    E --> I[Address Book]\n    I --> J{Action}\n    J --> K[Add Address]\n    J --> L[Edit Address]\n    J --> M[Delete Address]\n    J --> N[Set Default Address]\n    E --> O[Log Out]\n```\n\n### Cart Management Flow\n\n```mermaid\nflowchart TD\n    A[Add to Cart] --> B[Cart Drawer Opens]\n    B --> C{Action}\n    C --> D[Update Quantity]\n    D --> B\n    C --> E[Remove Item]\n    E --> F{Cart Empty?}\n    F -->|Yes| G[Empty Cart State]\n    G --> H[Continue Shopping]\n    F -->|No| B\n    C --> I[Apply Discount Code]\n    I --> J{Valid?}\n    J -->|Yes| K[Discount Applied]\n    K --> B\n    J -->|No| L[Error Message]\n    L --> B\n    C --> M[Proceed to Checkout]\n    M --> N[Checkout Page]\n    C --> O[View Full Cart Page]\n    O --> N\n```\n\n### Component State Diagram\n\n```mermaid\nstateDiagram-v2\n    [*] --> Empty: Cart initialized\n\n    state \"Cart Drawer\" as CD {\n        Empty --> HasItems: Add item\n        HasItems --> HasItems: Update quantity\n        HasItems --> HasItems: Apply discount\n        HasItems --> Empty: Remove last item\n        HasItems --> Checkout: Proceed to checkout\n    }\n\n    state \"Product Page\" as PP {\n        NoVariant --> VariantSelected: Select options\n        VariantSelected --> InStock: Stock > 0\n        VariantSelected --> OutOfStock: Stock = 0\n        InStock --> Adding: Click add to cart\n        Adding --> InStock: Complete\n        OutOfStock --> SoldOut: Deny policy\n        OutOfStock --> Backorder: Continue policy\n    }\n\n    state \"Checkout\" as CO {\n        Contact --> ShippingAddress: Complete step 1\n        ShippingAddress --> ShippingMethod: Complete step 2\n        ShippingMethod --> Payment: Complete step 3\n        Payment --> Processing: Submit payment\n        Processing --> Confirmation: Payment success\n        Processing --> Payment: Payment failure\n    }\n```\n\n---\n\n## 21. Flash / Toast Messages\n\n| Action | Message | Type |\n|--------|---------|------|\n| Item added to cart | \"Added to cart\" | success |\n| Item removed from cart | \"Item removed\" | success |\n| Cart is empty | \"Your cart is empty\" | info |\n| Discount applied | \"Discount applied\" | success |\n| Invalid discount code | \"Invalid discount code\" | error |\n| Out of stock | \"This product is currently out of stock\" | warning |\n| Registration success | \"Account created successfully\" | success |\n| Login success | (redirect, no message) | - |\n| Login failed | \"Invalid credentials\" | error |\n| Password reset sent | \"We have emailed your password reset link!\" | success |\n| Order placed | \"Thank you for your order!\" | success |\n","structuredPatch":[{"oldStart":1817,"oldLines":9,"newStart":1817,"newLines":8,"lines":["     M --> N[Step 1: Contact Info]","     N --> O[Step 2: Shipping Address]","     O --> P[Step 3: Shipping Method]","-    P --> Q[Step 4: Payment]","-    Q --> R[Stripe/PayPal Redirect]","-    R --> S[Order Confirmation]","+    P --> Q[Step 4: Payment Method & Pay]","+    Q --> S[Order Confirmation]","     S --> T{Next?}","     T -->|Continue Shopping| A","     T -->|View Order| U[Account Order Detail]"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"a868ac4f-bc5b-4433-a3ea-63923b6a6ca5"}
{"type":"file-history-snapshot","messageId":"f22ea2b0-7a54-4f44-b615-8db21e07ef49","snapshot":{"messageId":"e438c861-f6cb-4565-97e0-d362dcaf336c","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v1","version":1,"backupTime":"2026-02-11T15:12:59.194Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v1","version":1,"backupTime":"2026-02-11T15:14:23.948Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v1","version":1,"backupTime":"2026-02-11T15:16:45.432Z"},"specs/03-ADMIN-UI.md":{"backupFileName":"a791bee027d52553@v1","version":1,"backupTime":"2026-02-11T15:21:51.319Z"},"specs/04-STOREFRONT-UI.md":{"backupFileName":"401411061d8a6ac1@v1","version":1,"backupTime":"2026-02-11T15:22:26.455Z"},"specs/07-SEEDERS-AND-TEST-DATA.md":{"backupFileName":"c11f09e3e5d3a009@v1","version":1,"backupTime":"2026-02-11T15:23:20.806Z"},"specs/08-PLAYWRIGHT-E2E-PLAN.md":{"backupFileName":"2744918423fe477c@v1","version":1,"backupTime":"2026-02-11T15:25:53.175Z"},"specs/09-IMPLEMENTATION-ROADMAP.md":{"backupFileName":"1f0335ca0ea0d4c4@v1","version":1,"backupTime":"2026-02-11T15:27:18.885Z"},"specs/06-AUTH-AND-SECURITY.md":{"backupFileName":"a579def47b6be477@v1","version":1,"backupTime":"2026-02-11T15:29:13.963Z"}},"timestamp":"2026-02-11T15:07:06.416Z"},"isSnapshotUpdate":true}
{"parentUuid":"66457b7b-42b1-42ce-acfc-a51bb8e8ab85","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KxhGd4ZZqx4MQN1oyn2cWk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ASZUrsJF4k67xNkMDghdWP","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","old_string":"| Webhook routes | `api`, `throttle:webhooks` | Incoming webhooks (Stripe, PayPal) - no auth |","new_string":"| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3233,"cache_read_input_tokens":154408,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3233},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X54xovLoiiAbditgQY","type":"assistant","uuid":"f22ea2b0-7a54-4f44-b615-8db21e07ef49","timestamp":"2026-02-11T15:29:13.960Z"}
{"parentUuid":"f22ea2b0-7a54-4f44-b615-8db21e07ef49","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01ASZUrsJF4k67xNkMDghdWP","toolUseID":"toolu_01ASZUrsJF4k67xNkMDghdWP","timestamp":"2026-02-11T15:29:13.965Z","uuid":"1a27a260-d48b-4ae2-b123-03fb0023ba57"}
{"parentUuid":"f22ea2b0-7a54-4f44-b615-8db21e07ef49","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ASZUrsJF4k67xNkMDghdWP","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md has been updated successfully."}]},"uuid":"e423870a-06d4-4258-bc43-84e190facd28","timestamp":"2026-02-11T15:29:13.965Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","oldString":"| Webhook routes | `api`, `throttle:webhooks` | Incoming webhooks (Stripe, PayPal) - no auth |","newString":"| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |","originalFile":"# 06 - Authentication and Security Specification\n\n> Shopify-clone e-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite\n\n---\n\n## 1. Authentication\n\nThe platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.\n\n### 1.1 Admin Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `web` (default Laravel guard) |\n| Provider name | `users` (Eloquent driver) |\n| Model | `App\\Models\\User` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /admin/login` |\n| Login form | Livewire component `Admin\\Auth\\Login` |\n\n#### Admin Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (web)\n    participant DB as Database\n    participant S as Session\n\n    B->>LW: Submit email + password\n    LW->>LW: Validate input (email required, password required)\n    LW->>LW: Check rate limiter (5/min per IP)\n    alt Rate limit exceeded\n        LW-->>B: Flash \"Too many attempts. Try again in X seconds.\"\n    end\n    LW->>Auth: attempt(credentials, remember)\n    Auth->>DB: SELECT from users WHERE email = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Credentials valid\n        Auth->>S: Regenerate session ID (prevent fixation)\n        Auth-->>LW: Success\n        LW-->>B: Redirect to /admin (dashboard)\n    else Credentials invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\" (generic, never reveal which field)\n    end\n```\n\n**Requirements:**\n\n- The login form collects `email` and `password`.\n- On successful login, the session MUST be regenerated to prevent session fixation attacks.\n- On failed login, only the generic message \"Invalid credentials\" is shown. The system must not indicate whether the email or password was wrong.\n- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.\n- After login: redirect to `/admin` (the dashboard).\n\n#### Remember Me\n\n- An optional \"Remember me\" checkbox on the login form.\n- When enabled, Laravel sets a long-lived remember token cookie.\n- The `remember_token` column on the `users` table stores the hashed token.\n- Cookie name follows Laravel default convention: `remember_web_{hash}`.\n\n#### Admin Password Reset Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant App as Application\n    participant DB as Database\n    participant Mail as Mail Service\n\n    B->>App: GET /admin/forgot-password\n    App-->>B: Render forgot-password form\n    B->>App: POST /admin/forgot-password (email)\n    App->>DB: Look up user by email\n    alt User exists\n        App->>DB: Store reset token in password_reset_tokens table\n        App->>Mail: Send reset link email\n    end\n    App-->>B: \"If that email exists, we sent a reset link.\" (generic)\n    B->>App: GET /admin/reset-password/{token}\n    App-->>B: Render reset-password form\n    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)\n    App->>DB: Validate token (exists, not expired, matches email)\n    alt Valid token\n        App->>DB: Update user password (bcrypt hash)\n        App->>DB: Delete used token\n        App-->>B: Redirect to /admin/login with success flash\n    else Invalid/expired token\n        App-->>B: Error message\n    end\n```\n\n**Requirements:**\n\n- Uses Laravel's built-in password broker (`users` broker).\n- Reset tokens stored in the `password_reset_tokens` table.\n- Token expiration: 60 minutes (configurable in `config/auth.php` under `passwords.users.expire`).\n- Throttle: one reset email per 60 seconds per email address.\n- The \"forgot password\" response must be generic (do not reveal whether email exists).\n\n#### Admin Logout\n\n- Route: `POST /admin/logout` (via Livewire action or standard route).\n- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.\n- The response must not cache.\n\n#### Session Lifetime\n\n| Setting | Value |\n|---------|-------|\n| Lifetime | 120 minutes (configurable via `SESSION_LIFETIME` env var) |\n| Expire on close | No (session persists until lifetime expires) |\n\n---\n\n### 1.2 Customer Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `customer` (custom guard) |\n| Provider name | `customers` (Eloquent driver) |\n| Model | `App\\Models\\Customer` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /account/login` |\n| Registration route | `GET /account/register` |\n\n#### Customer Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (customer)\n    participant Store as ResolveStore Middleware\n    participant DB as Database\n\n    Note over Store: Store already resolved from hostname\n    B->>LW: Submit email + password\n    LW->>LW: Validate input\n    LW->>LW: Check rate limiter (5/min per IP)\n    LW->>Auth: attempt(credentials + store_id, remember)\n    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Valid\n        Auth-->>LW: Success (regenerate session)\n        LW-->>B: Redirect to /account (or intended URL)\n    else Invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\"\n    end\n```\n\n**Requirements:**\n\n- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.\n- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.\n- The same email address can exist in different stores (multi-tenant isolation).\n- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.\n- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).\n\n#### Customer Registration Flow\n\n```mermaid\nflowchart TD\n    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]\n    B --> C{Validate via RegisterCustomerRequest}\n    C -- Invalid --> D[Show validation errors]\n    C -- Valid --> E[Create Customer record scoped to current store]\n    E --> F[Auto-login via customer guard]\n    F --> G[Redirect to /account]\n```\n\n**Registration fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|:--------:|------------|\n| name | string | Y | max:255 |\n| email | string | Y | valid email, unique per store (composite unique on email + store_id) |\n| password | string | Y | min:8, confirmed |\n| password_confirmation | string | Y | must match password |\n| marketing_opt_in | boolean | N | defaults to false |\n\n#### Customer Store Scoping\n\n- The `customers` auth provider requires a custom `CustomerUserProvider` class.\n- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.\n- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).\n- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.\n\n#### Customer Password Reset\n\n- Uses a separate password broker: `customers` broker.\n- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).\n- Token records include `store_id` to scope resets to the correct store.\n- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.\n- Token expiration: 60 minutes.\n- Throttle: one reset email per 60 seconds.\n- Generic response on the \"forgot password\" form (do not reveal whether email exists in the store).\n\n---\n\n### 1.3 API Authentication (Sanctum)\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `sanctum` |\n| Provider | `users` (admin users only) |\n| Token type | Personal access tokens |\n| Token prefix | `shop_` |\n| Hashing | SHA-256 |\n| Default expiration | 1 year |\n| Storage | `personal_access_tokens` table |\n\n#### Token Lifecycle\n\n```mermaid\nstateDiagram-v2\n    [*] --> Created: Admin creates token in Developers section\n    Created --> Active: Plain-text shown once, hash stored\n    Active --> InUse: Token sent as Bearer header\n    InUse --> Active: Request processed\n    Active --> Revoked: Admin revokes token\n    Active --> Expired: Expiration date reached\n    Revoked --> [*]: Token row deleted\n    Expired --> [*]: Token row deleted (cleanup job)\n```\n\n**Requirements:**\n\n- Tokens are created in the admin panel, under the Developers section.\n- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).\n- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.\n- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.\n- Sanctum middleware validates the hash and resolves the associated user.\n- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).\n- Revoking a token deletes its row from the `personal_access_tokens` table.\n\n#### Token Abilities (Scopes)\n\n| Ability | Description |\n|---------|-------------|\n| `read-products` | List and view products |\n| `write-products` | Create, update, delete products |\n| `read-orders` | List and view orders |\n| `write-orders` | Update orders, create fulfillments |\n| `read-customers` | List and view customers |\n| `write-customers` | Update customers |\n| `read-collections` | List and view collections |\n| `write-collections` | Create, update, delete collections |\n| `read-discounts` | List and view discounts |\n| `write-discounts` | Create, update, delete discounts |\n| `read-analytics` | View analytics data |\n| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |\n| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |\n| `read-themes` | View themes and theme files |\n| `write-themes` | Create, update, delete, publish themes |\n| `read-content` | View pages and navigation menus |\n| `write-content` | Create, update, delete pages and navigation items |\n| `manage-platform` | Platform-level management (super-admin only) |\n\nEach API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.\n\n---\n\n### 1.4 Guard Configuration\n\nFile to configure: `config/auth.php`\n\n#### Guards Data Dictionary\n\n| Guard Name | Driver | Provider | Purpose |\n|------------|--------|----------|---------|\n| `web` | session | `users` | Admin panel session auth (default guard) |\n| `customer` | session | `customers` | Storefront customer session auth |\n| `sanctum` | sanctum | `users` | Admin REST API token auth |\n\n#### Providers Data Dictionary\n\n| Provider Name | Driver | Model | Purpose |\n|---------------|--------|-------|---------|\n| `users` | eloquent | `App\\Models\\User` | Admin users |\n| `customers` | eloquent | `App\\Models\\Customer` | Storefront customers (store-scoped via custom provider) |\n\n#### Password Brokers Data Dictionary\n\n| Broker Name | Provider | Token Table | Expiry | Throttle |\n|-------------|----------|-------------|--------|----------|\n| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |\n| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |\n\n**Defaults:** The default guard is `web` and the default password broker is `users`.\n\n---\n\n## 2. Authorization\n\n### 2.1 Role Enum\n\nFile: `app/Enums/StoreUserRole.php`\n\nA backed string enum with four values:\n\n| Case | Stored Value | Description |\n|------|-------------|-------------|\n| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |\n| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |\n| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |\n| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |\n\n**Constraints:**\n\n- Each store must have exactly one Owner.\n- Ownership transfer is a dedicated action (not a simple role change).\n- A User can hold different roles in different stores (via the `store_users` pivot table).\n\n---\n\n### 2.2 Permission Matrix\n\nThe `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.\n\n| Resource | Action | Owner | Admin | Staff | Support |\n|----------|--------|:-----:|:-----:|:-----:|:-------:|\n| Store Settings | View | Y | Y | N | N |\n| Store Settings | Update | Y | Y | N | N |\n| Store | Delete | Y | N | N | N |\n| Products | List / View | Y | Y | Y | Y |\n| Products | Create | Y | Y | Y | N |\n| Products | Update | Y | Y | Y | N |\n| Products | Delete / Archive | Y | Y | N | N |\n| Products | Restore | Y | Y | N | N |\n| Collections | List / View | Y | Y | Y | Y |\n| Collections | Create | Y | Y | Y | N |\n| Collections | Update | Y | Y | Y | N |\n| Collections | Delete | Y | Y | N | N |\n| Orders | List / View | Y | Y | Y | Y |\n| Orders | Update | Y | Y | Y | N |\n| Orders | Cancel | Y | Y | N | N |\n| Fulfillments | Create | Y | Y | Y | N |\n| Fulfillments | Update / Cancel | Y | Y | Y | N |\n| Refunds | Create | Y | Y | N | N |\n| Customers | List / View | Y | Y | Y | Y |\n| Customers | Update | Y | Y | Y | N |\n| Discounts | List / View | Y | Y | Y | Y |\n| Discounts | Create | Y | Y | Y | N |\n| Discounts | Update | Y | Y | Y | N |\n| Discounts | Delete | Y | Y | N | N |\n| Themes | List / View | Y | Y | N | N |\n| Themes | Create / Upload | Y | Y | N | N |\n| Themes | Customize | Y | Y | N | N |\n| Themes | Publish | Y | Y | N | N |\n| Themes | Delete | Y | Y | N | N |\n| Pages | List / View | Y | Y | Y | N |\n| Pages | Create | Y | Y | Y | N |\n| Pages | Update | Y | Y | Y | N |\n| Pages | Delete | Y | Y | N | N |\n| Navigation | List / View | Y | Y | Y | N |\n| Navigation | Manage | Y | Y | N | N |\n| Apps | List / View | Y | Y | N | N |\n| Apps | Install / Remove | Y | Y | N | N |\n| Analytics | View | Y | Y | Y | N |\n| Developers / API | Manage | Y | Y | N | N |\n| Staff | List / View | Y | Y | N | N |\n| Staff | Invite | Y | Y | N | N |\n| Staff | Update Role | Y | Y | N | N |\n| Staff | Remove | Y | Y | N | N |\n| Shipping Zones | Manage | Y | Y | N | N |\n| Tax Settings | Manage | Y | Y | N | N |\n| Search Settings | Manage | Y | Y | N | N |\n\n#### Role Group Shorthand\n\nThe permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:\n\n| Group Name | Roles Included | Used For |\n|------------|---------------|----------|\n| OwnerOnly | Owner | Store deletion, ownership transfer |\n| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |\n| OwnerAdminOrStaff | Owner, Admin, Staff | Create/update products, collections, discounts, pages, fulfillments, order updates |\n| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |\n\n---\n\n### 2.3 Role Checking Trait\n\nFile: `app/Traits/ChecksStoreRole.php`\n\nA reusable trait included in all policies and used by gate definitions. Must provide the following methods:\n\n| Method | Parameters | Returns | Description |\n|--------|-----------|---------|-------------|\n| `getStoreRole` | User, store_id (int) | StoreUserRole or null | Looks up the user's role for the given store via the `store_users` relationship. Returns null if the user has no role in that store. |\n| `hasRole` | User, store_id (int), roles (array of StoreUserRole) | bool | Returns true if the user's role for the store is in the provided list. Returns false if user has no role. |\n| `isOwnerOrAdmin` | User, store_id (int) | bool | Shorthand: checks if role is Owner or Admin. |\n| `isOwnerAdminOrStaff` | User, store_id (int) | bool | Shorthand: checks if role is Owner, Admin, or Staff. |\n| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |\n\n**Store ID resolution:** When the policy method receives a model instance (e.g., Product), the store_id comes from the model's `store_id` attribute. When the policy method does not receive a model (e.g., `viewAny`, `create`), the store_id is resolved from the container-bound `current_store`.\n\n---\n\n### 2.4 Laravel Policies\n\nAll policies use the `ChecksStoreRole` trait. Laravel 12 auto-discovers policies by convention (`App\\Policies\\{Model}Policy`), so no manual registration is needed.\n\nEvery policy method follows this pattern:\n1. Determine the `store_id` (from the model if available, otherwise from the resolved current store).\n2. Use the appropriate role-checking helper method.\n3. Return a boolean.\n\n#### ProductPolicy\n\nFile: `app/Policies/ProductPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Product)` | Yes | AnyRole | product.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Product)` | Yes | OwnerAdminOrStaff | product.store_id |\n| `delete(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `archive(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `restore(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n\n#### OrderPolicy\n\nFile: `app/Policies/OrderPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Order)` | Yes | AnyRole | order.store_id |\n| `update(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `cancel(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n| `createFulfillment(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `createRefund(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n\n#### CollectionPolicy\n\nFile: `app/Policies/CollectionPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Collection)` | Yes | AnyRole | collection.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Collection)` | Yes | OwnerAdminOrStaff | collection.store_id |\n| `delete(User, Collection)` | Yes | OwnerOrAdmin | collection.store_id |\n\n#### DiscountPolicy\n\nFile: `app/Policies/DiscountPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Discount)` | Yes | AnyRole | discount.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Discount)` | Yes | OwnerAdminOrStaff | discount.store_id |\n| `delete(User, Discount)` | Yes | OwnerOrAdmin | discount.store_id |\n\n#### CustomerPolicy\n\nFile: `app/Policies/CustomerPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Customer)` | Yes | AnyRole | customer.store_id |\n| `update(User, Customer)` | Yes | OwnerAdminOrStaff | customer.store_id |\n\n#### ThemePolicy\n\nFile: `app/Policies/ThemePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerOrAdmin | current_store |\n| `view(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `create(User)` | No | OwnerOrAdmin | current_store |\n| `update(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `delete(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `publish(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n\n#### PagePolicy\n\nFile: `app/Policies/PagePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `view(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `delete(User, Page)` | Yes | OwnerOrAdmin | page.store_id |\n\n#### StorePolicy\n\nFile: `app/Policies/StorePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `updateSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `delete(User, Store)` | Yes | OwnerOnly | store.id |\n\n#### FulfillmentPolicy\n\nFile: `app/Policies/FulfillmentPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerAdminOrStaff | order.store_id |\n| `update(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n| `cancel(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n\nNote: `create` receives the parent Order, not a Fulfillment, since the Fulfillment does not exist yet. The `update` and `cancel` methods navigate through the Fulfillment's order relationship to get the store_id.\n\n#### RefundPolicy\n\nFile: `app/Policies/RefundPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerOrAdmin | order.store_id |\n\nNote: `create` receives the parent Order for the same reason as FulfillmentPolicy.\n\n#### NavigationMenuPolicy\n\nFile: `app/Policies/NavigationMenuPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `manage(User)` | No | OwnerOrAdmin | current_store |\n\n---\n\n### 2.5 Gates (Non-Model Operations)\n\nGates are registered in `AppServiceProvider` (or a dedicated `AuthServiceProvider`). Each gate resolves the current store from the container and checks the user's role via the `store_users` relationship.\n\n| Gate Name | Required Roles | Purpose |\n|-----------|---------------|---------|\n| `manage-store-settings` | Owner, Admin | Access store settings pages |\n| `manage-staff` | Owner, Admin | Invite, update, remove staff members |\n| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |\n| `view-analytics` | Owner, Admin, Staff | View analytics dashboard and reports |\n| `manage-shipping` | Owner, Admin | Manage shipping zones, rates, profiles |\n| `manage-taxes` | Owner, Admin | Manage tax settings and rates |\n| `manage-search-settings` | Owner, Admin | Configure search weights and synonyms |\n| `manage-navigation` | Owner, Admin | Create/edit/delete navigation menus |\n| `manage-apps` | Owner, Admin | Install and remove third-party apps |\n\n**Gate resolution pattern (all gates follow this):**\n\n1. Resolve the current store from the container\n2. Look up the user's `store_users` record for that store\n3. If no record exists, return false\n4. If the user's role is in the gate's required roles list, return true\n5. Otherwise return false\n\n---\n\n### 2.6 Policy and Gate Usage Patterns\n\n#### In Livewire Components\n\n- List views: call `authorize('viewAny', ModelClass)` in the `mount()` lifecycle method.\n- Action methods (delete, update, etc.): call `authorize('action', $modelInstance)` at the start of the action, before performing any mutation.\n- If authorization fails, Laravel throws `AuthorizationException`, which renders as 403.\n\n#### In API Controllers\n\n- Use `$this->authorize('action', $model)` at the start of each controller method.\n- Alternatively, use `can` middleware on routes for simpler checks.\n- Form Requests also implement `authorize()` to check the relevant policy or gate before validation runs.\n\n#### In Blade Templates\n\n- Use `@can('gate-name')` / `@cannot` directives to conditionally show/hide navigation items and action buttons.\n- This is purely cosmetic (UI hiding) - the actual authorization check happens server-side in policies/gates.\n\n---\n\n## 3. Middleware Stack\n\n### 3.1 Middleware Registration\n\nFile: `bootstrap/app.php`\n\nThree custom middleware aliases must be registered:\n\n| Alias | Class | Purpose |\n|-------|-------|---------|\n| `store.resolve` | `App\\Http\\Middleware\\ResolveStore` | Resolves current store from hostname or session |\n| `role.check` | `App\\Http\\Middleware\\CheckStoreRole` | Checks user has required role for current store |\n| `auth:customer` | `App\\Http\\Middleware\\CustomerAuthenticate` | Ensures customer is authenticated via customer guard |\n\n---\n\n### 3.2 Route Middleware Groups\n\n| Route Group | Middleware Stack | Description |\n|-------------|-----------------|-------------|\n| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n| Webhook routes | `api`, `throttle:webhooks` | Incoming webhooks (Stripe, PayPal) - no auth |\n\n---\n\n### 3.3 Custom Middleware Specifications\n\n#### ResolveStore\n\nFile: `app/Http/Middleware/ResolveStore.php`\n\n**Purpose:** Resolves the current store from the request hostname and binds it to the service container.\n\n```mermaid\nflowchart TD\n    A[Extract hostname from request] --> B{Route type?}\n    B -- Storefront --> C{Match hostname in store_domains table?}\n    C -- Yes --> F[Store found]\n    C -- No --> E[Abort 404: Store not found]\n    F --> G{Store status == suspended?}\n    G -- Yes --> H[Abort 503: Store unavailable]\n    G -- No --> I[Bind Store to container as 'current_store']\n    I --> J[Share Store with all views as 'currentStore']\n    J --> K[Continue to next middleware]\n    B -- Admin --> L[Read store_id from session 'current_store_id']\n    L --> M{User has store_users record?}\n    M -- No --> N[Abort 403]\n    M -- Yes --> I\n```\n\n**Requirements:**\n\nStorefront requests: Resolve store from the `store_domains` table by matching the request hostname against `store_domains.hostname`. Cache the hostname-to-store mapping using file cache (tag: `store-domains`).\n\nAdmin requests: Store is determined by session key `current_store_id`, set after login or via the store-switcher in the admin panel. Admin routes do NOT use hostname resolution. The `admin.acme-fashion.test` domain in seeders routes requests to the admin panel, but the actual store context within the admin is determined by the session `current_store_id` after login.\n\n**Shared steps (after store is resolved):**\n\n1. If the store's `status` column is `'suspended'`: abort with HTTP 503 and message \"This store is currently unavailable.\" (For admin routes: abort 403 for mutations instead.)\n2. Bind the resolved Store model instance to the service container with the key `'current_store'`.\n3. Share the store with all Blade views under the variable name `currentStore`.\n4. Allow the request to proceed.\n\n#### CheckStoreRole\n\nFile: `app/Http/Middleware/CheckStoreRole.php`\n\n**Purpose:** Verifies the authenticated user has one of the specified roles for the current store.\n\n**Route usage:** Applied as `role.check:owner,admin` or `role.check:owner,admin,staff` (comma-separated role values passed as variadic parameters).\n\n```mermaid\nflowchart TD\n    A[Resolve current_store from container] --> B[Get authenticated user]\n    B --> C[Look up store_users record for user + store]\n    C --> D{Record exists?}\n    D -- No --> E[Abort 403: No access to this store]\n    D -- Yes --> F[Parse allowed roles from middleware parameters]\n    F --> G{User's role in allowed list?}\n    G -- No --> H[Abort 403: Insufficient permissions]\n    G -- Yes --> I[Attach store_user to request attributes]\n    I --> J[Continue to next middleware]\n```\n\n**Requirements:**\n\n1. Resolve the current store from the container.\n2. Get the authenticated user from the request.\n3. Query the user's `store_users` relationship filtered by `store_id`.\n4. If no record exists: abort 403 with message \"You do not have access to this store.\"\n5. Convert the middleware parameter strings to `StoreUserRole` enum instances.\n6. If the user's role is not in the allowed list: abort 403 with message \"Insufficient permissions.\"\n7. Store the resolved `StoreUser` pivot record on the request attributes (key: `store_user`) for downstream use.\n8. Allow the request to proceed.\n\n#### CustomerAuthenticate\n\nFile: `app/Http/Middleware/CustomerAuthenticate.php`\n\n**Purpose:** Ensures the request is authenticated via the `customer` guard. Redirects unauthenticated customers to login.\n\n```mermaid\nflowchart TD\n    A[Check customer guard authentication] --> B{Authenticated?}\n    B -- Yes --> C[Continue to next middleware]\n    B -- No --> D[Store current URL as intended redirect]\n    D --> E[Redirect to /account/login]\n```\n\n**Requirements:**\n\n1. Check if `Auth::guard('customer')` reports the user as authenticated.\n2. If authenticated: allow the request to proceed.\n3. If not authenticated: save the current URL in the session as the intended URL (so the customer returns there after login), then redirect to the storefront login route (`/account/login`).\n\n---\n\n## 4. Security Controls\n\n### 4.1 CSRF Protection\n\n| Context | Mechanism |\n|---------|-----------|\n| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |\n| Livewire actions | Livewire handles CSRF verification automatically on every request |\n| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |\n| SPA clients | `GET /sanctum/csrf-cookie` sets XSRF-TOKEN cookie for session-based API calls |\n\n**Requirements:**\n\n- CSRF verification is enforced globally on all `web` middleware group routes.\n- No routes within the `web` group may be excluded from CSRF verification.\n- API routes (under `api` middleware group) are inherently exempt since they use token auth.\n\n---\n\n### 4.2 Rate Limiting\n\nAll rate limiters are defined in `bootstrap/app.php` (or `AppServiceProvider::boot()`).\n\n| Limiter Name | Limit | Keyed By | Applied To |\n|-------------|-------|----------|------------|\n| `login` | 5 per minute | IP address | Admin login, customer login |\n| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |\n| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |\n| `checkout` | 10 per minute | Session ID | Checkout-related operations |\n| `search` | 30 per minute | IP address | Search queries |\n| `analytics` | 60 per minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |\n\n**Rate limit response headers** (returned on all API responses by Laravel default):\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429 responses) |\n\nWhen a rate limit is exceeded, the response is HTTP 429 Too Many Requests.\n\n---\n\n### 4.3 Encryption at Rest\n\nEncryption algorithm: AES-256-CBC with MAC verification, keyed by the application's `APP_KEY`.\n\n**Fields requiring encrypted storage (using Laravel's `encrypted` cast):**\n\n| Model | Column | Cast Type | Purpose |\n|-------|--------|-----------|---------|\n| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |\n| WebhookSubscription | `signing_secret_encrypted` | `encrypted` | Per-subscription signing secrets for outbound webhooks |\n| Payment | `raw_json` | `encrypted:array` | Full payment gateway response (may contain sensitive card/account data) |\n\n**Requirements:**\n\n- These columns are declared with the `encrypted` (or `encrypted:array`) cast in the model's `casts()` method.\n- Data is automatically encrypted before writing to the database and decrypted when reading.\n- The encrypted columns store opaque ciphertext (type TEXT in SQLite). They cannot be queried with WHERE clauses at the database level.\n\n---\n\n### 4.4 Input Validation\n\nAll write endpoints must use dedicated Form Request classes. Validation rules use string syntax per project convention.\n\n#### Form Request Inventory\n\n| Form Request Class | Used By | Key Validation Rules |\n|-------------------|---------|---------------------|\n| `StoreProductRequest` | Create product | `title`: required, string, max:255. `description_html`: nullable, string. `status`: required, in:active,draft,archived. `vendor`: nullable, string. Price fields: required, integer, min:0. |\n| `UpdateProductRequest` | Update product | Same fields as StoreProductRequest but all optional (sometimes rule). |\n| `StoreCollectionRequest` | Create collection | `title`: required, string, max:255. `type`: required, in:manual,automated. `rules_json`: required_if:type,automated, valid JSON. |\n| `UpdateCollectionRequest` | Update collection | Same as StoreCollectionRequest, all optional. |\n| `StoreDiscountRequest` | Create discount | `type`: required, in:code,automatic. `code`: required_if:type,code, string, max:50, unique per store (case-insensitive). `value_type`: required, in:fixed,percent,free_shipping. `value_amount`: required_unless:value_type,free_shipping, integer, min:0; when value_type is `percent`, max:100. `minimum_order_amount`: nullable, integer, min:0. `usage_limit`: nullable, integer, min:1. `starts_at`: nullable, date, ISO-8601. `ends_at`: nullable, date, after:starts_at. |\n| `UpdateDiscountRequest` | Update discount | Same as StoreDiscountRequest, code unique ignoring self. |\n| `SetCheckoutAddressRequest` | Set shipping/billing address | `first_name`, `last_name`, `address1`, `city`, `country_code`: required. `province_code`: required when applicable. `postal_code`: required. `phone`: nullable. |\n| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |\n| `CreateFulfillmentRequest` | Create fulfillment | `line_item_ids`: required, array of existing line item IDs. `tracking_company`: nullable, string. `tracking_number`: nullable, string. `tracking_url`: nullable, valid URL. |\n| `CreateRefundRequest` | Create refund | `line_items`: required, array with `id` and `quantity` per item. `reason`: required, string. `amount`: required, integer, min:0, max:original payment amount. |\n| `UpdateStoreSettingsRequest` | Update store settings | `name`: required, string. `default_currency`: in allowed currencies. `default_locale`: in allowed locales. `timezone`: valid timezone string. |\n| `InviteStaffRequest` | Invite staff member | `email`: required, valid email. `role`: required, in:admin,staff,support (not owner). |\n| `RegisterCustomerRequest` | Customer registration | `name`: required. `email`: required, email, unique per store (composite). `password`: required, min:8, confirmed. |\n| `LoginRequest` | Admin/customer login | `email`: required, email. `password`: required, string. |\n\n**Authorization in Form Requests:**\n\nEach Form Request's `authorize()` method must check the relevant policy or gate. For example, `StoreProductRequest::authorize()` checks whether the user can create a Product. If authorization fails, the request is rejected with 403 before validation even runs.\n\n---\n\n### 4.5 XSS Prevention\n\n| Context | Strategy |\n|---------|----------|\n| Standard Blade output | Double-curly-brace syntax auto-escapes all output (Laravel default) |\n| Rich HTML output | Unescaped syntax used only for trusted HTML fields: product `description_html`, page `body_html` |\n| HTML input sanitization | Use HTMLPurifier (or equivalent) on save, before persisting to database |\n| Livewire properties | Auto-escaped by default when rendered in Blade |\n| JSON API responses | Laravel escapes JSON output by default |\n\n#### HTML Sanitization Requirements\n\nAn `SanitizeHtml` action class must be created at `app/Actions/SanitizeHtml.php`.\n\n**Allowed HTML elements and attributes:**\n\n| Element | Allowed Attributes |\n|---------|-------------------|\n| `p`, `br`, `strong`, `em`, `u` | none |\n| `ol`, `ul`, `li` | none |\n| `a` | `href` |\n| `img` | `src`, `alt` |\n| `h1` through `h6` | none |\n| `blockquote` | none |\n| `table`, `thead`, `tbody`, `tr`, `th`, `td` | none |\n| `div`, `span` | none |\n\nAll other elements and attributes must be stripped. Empty elements should be removed.\n\n**Where sanitization is applied:** Before persisting product `description_html` and page `body_html` fields. The sanitization runs during the create and update operations for these models.\n\n---\n\n### 4.6 SQL Injection Prevention\n\n**Requirements:**\n\n- All database queries must use Eloquent ORM with parameterized bindings (Laravel default behavior).\n- No raw query strings may contain unsanitized user input.\n- Any use of `whereRaw`, `selectRaw`, or `DB::raw` must use parameter bindings (the `?` placeholder syntax).\n- FTS5 (full-text search) queries require special handling: user input must be sanitized before building the MATCH expression.\n\n#### FTS5 Input Sanitization\n\nBefore using user input in an FTS5 MATCH expression:\n\n1. Strip all characters that are not Unicode letters, Unicode numbers, or whitespace (remove FTS5 special operators like `*`, `AND`, `OR`, `NOT`, `NEAR`, quotes, parentheses, etc.).\n2. Trim leading and trailing whitespace.\n3. Pass the sanitized string via a parameterized binding (never concatenate into the query string).\n\n---\n\n### 4.7 Mass Assignment Protection\n\n**Requirements:**\n\n- Every Eloquent model must define a `$fillable` array listing only the columns that are safe for mass assignment.\n- The pattern `$guarded = []` is never permitted in any model.\n- All controller and Livewire action code must use `$request->validated()` (not `$request->all()` or `$request->input()`) when passing data to model creation or update methods.\n\n---\n\n### 4.8 Password Security\n\n| Setting | Value |\n|---------|-------|\n| Minimum length | 8 characters (for both admin and customer passwords) |\n| Hashing algorithm | Bcrypt (Laravel default) |\n| Password in logs | Never logged - use Laravel's built-in log redaction |\n| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |\n\n**Model requirements:**\n\n- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.\n- Password validation rules must enforce minimum 8 characters and use the `confirmed` rule (requiring a matching `password_confirmation` field).\n\n**Important:** Both the `User` and `Customer` models use `password_hash` as the database column name instead of Laravel's default `password`. Both models must override `getAuthPassword()`:\n\n```php\npublic function getAuthPassword(): string\n{\n    return $this->password_hash;\n}\n```\n\nThis is required for `Auth::attempt()` and `Hash::check()` to work correctly with the custom column name.\n\n---\n\n## 5. Session Configuration\n\nFile: `config/session.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `driver` | `'file'` | Simple, no external dependencies. Compatible with SQLite-based stack without needing Redis. |\n| `lifetime` | 120 (minutes) | Configurable via `SESSION_LIFETIME` env var. |\n| `expire_on_close` | `false` | Session persists until lifetime expires, not on browser close. |\n| `encrypt` | `true` | Session data encrypted at rest on disk. |\n| `cookie` | `'shop_session'` | Custom name to avoid generic fingerprinting. Configurable via `SESSION_COOKIE` env var. |\n| `path` | `'/'` | Cookie available across entire domain. |\n| `domain` | `null` | Laravel determines from request. |\n| `secure` | `true` | HTTPS-only cookies in production. Configurable via `SESSION_SECURE_COOKIE` env var (set to false for local dev). |\n| `http_only` | `true` | Cookie inaccessible to JavaScript (mitigates XSS-based cookie theft). |\n| `same_site` | `'lax'` | Allows navigation-initiated requests while preventing CSRF from external origins. |\n| `partitioned` | `false` | Not needed for this use case. |\n\n---\n\n## 6. CORS Configuration\n\nFile: `config/cors.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `paths` | `['api/*', 'sanctum/csrf-cookie']` | Only API and Sanctum CSRF cookie endpoints are CORS-enabled. Web routes are not. |\n| `allowed_methods` | `['*']` | All HTTP methods allowed for API. |\n| `allowed_origins` | `[FRONTEND_URL env var, default '*']` | Restrict to store domain in production. Wildcard for development. |\n| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |\n| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |\n| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |\n| `max_age` | `0` | No preflight caching (conservative default). |\n| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |\n\n---\n\n## 7. Webhook Signature Verification\n\n### 7.1 Stripe Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\StripeWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw request body]\n    B --> C[Get Stripe-Signature header]\n    C --> D[Get STRIPE_WEBHOOK_SECRET from config]\n    D --> E{Verify signature using Stripe SDK}\n    E -- Invalid --> F[Return 400 Bad Request]\n    E -- Valid --> G[Parse event type]\n    G --> H{Route to handler}\n    H --> I[checkout.session.completed]\n    H --> J[payment_intent.succeeded]\n    H --> K[charge.refunded]\n    H --> L[Unknown event - ignore]\n    I --> M[Return 200 OK]\n    J --> M\n    K --> M\n    L --> M\n```\n\n**Requirements:**\n\n1. Read the raw request body (not parsed JSON) because signature verification depends on the exact byte sequence.\n2. Retrieve the `Stripe-Signature` header from the incoming request.\n3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.\n4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.\n5. On signature verification failure: return 400 immediately.\n6. On success: dispatch to the appropriate handler based on event type, then return 200.\n7. Handled event types: `checkout.session.completed`, `payment_intent.succeeded`, `charge.refunded`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.2 PayPal Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw body and headers]\n    B --> C[Call PayPal verification API with webhook ID + headers + body]\n    C --> D{verification_status == SUCCESS?}\n    D -- No --> E[Return 400 Bad Request]\n    D -- Yes --> F[Parse event type from body]\n    F --> G{Route to handler}\n    G --> H[PAYMENT.CAPTURE.COMPLETED]\n    G --> I[PAYMENT.CAPTURE.REFUNDED]\n    G --> J[Unknown event - ignore]\n    H --> K[Return 200 OK]\n    I --> K\n    J --> K\n```\n\n**Requirements:**\n\n1. Read the raw request body and all relevant PayPal headers.\n2. Call PayPal's webhook verification API endpoint, passing the webhook ID, transmission headers, and request body.\n3. Check the `verification_status` field in the PayPal response. Only proceed if it equals `SUCCESS`.\n4. On verification failure: return 400.\n5. Handled event types: `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.3 Platform Webhooks (Outbound)\n\nService: `App\\Services\\WebhookDispatcher`\n\nWhen the platform sends webhooks to external subscribers (third-party apps, integrations).\n\n```mermaid\nsequenceDiagram\n    participant App as Platform\n    participant Sub as Subscriber URL\n\n    App->>App: Serialize payload as JSON\n    App->>App: Generate Unix timestamp\n    App->>App: Concatenate: \"{timestamp}.{json_body}\"\n    App->>App: HMAC-SHA256(signing_secret, concatenated_message)\n    App->>Sub: POST to subscriber URL\n    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json\n    Sub-->>App: 200 OK (acknowledged)\n```\n\n**Requirements:**\n\n1. The payload is serialized as JSON.\n2. A Unix timestamp is generated (current time).\n3. The message to sign is: `\"{timestamp}.{json_body}\"` (timestamp, dot, JSON string).\n4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).\n5. The HTTP request to the subscriber includes three headers:\n   - `X-Platform-Signature`: the hex-encoded HMAC digest\n   - `X-Platform-Timestamp`: the Unix timestamp as a string\n   - `Content-Type`: `application/json`\n6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.\n\n---\n\n## 8. Audit Logging\n\n### 8.1 Logging Channel\n\nA dedicated `audit` channel must be added to `config/logging.php`.\n\n| Setting | Value |\n|---------|-------|\n| Driver | `daily` (file-based, daily rotation) |\n| Path | `storage/logs/audit.log` |\n| Level | `info` |\n| Retention | 90 days |\n\n---\n\n### 8.2 Log Entry Format\n\nAll audit log entries are structured JSON objects with the following schema:\n\n| Field | Type | Required | Description |\n|-------|------|:--------:|-------------|\n| `timestamp` | string (ISO-8601) | Y | When the event occurred |\n| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |\n| `user_id` | integer or null | N | ID of the admin user who triggered the event |\n| `store_id` | integer or null | N | ID of the store context |\n| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |\n| `resource_id` | integer or null | N | ID of the affected resource |\n| `ip` | string | Y | Client IP address |\n| `user_agent` | string | Y | Client user agent string |\n| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |\n| (additional keys) | varies | N | Event-specific extra data |\n\n---\n\n### 8.3 Events to Log\n\n#### Authentication Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `auth.login` | Successful admin login | user_id, ip, user_agent |\n| `auth.logout` | Admin logout | user_id |\n| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |\n| `customer.login` | Successful customer login | customer_id, store_id, ip |\n| `customer.registered` | New customer registration | customer_id, store_id |\n| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |\n\n#### Admin Resource Events\n\nFor each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:\n\n**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff\n\n| Event Pattern | Trigger | Extra Data |\n|---------------|---------|------------|\n| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |\n| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |\n| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |\n| `{resource}.restored` | Resource restored from soft-delete | resource_id |\n\n#### Security Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `api_token.created` | New API token issued | token_name, abilities list, user_id |\n| `api_token.revoked` | API token revoked | token_name, user_id |\n| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |\n| `store.settings_changed` | Store settings updated | changed fields with old/new values |\n\n---\n\n### 8.4 Audit Logger Service\n\nFile: `app/Services/AuditLogger.php`\n\nA service class that encapsulates audit log writing.\n\n**Method signature:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|:--------:|-------------|\n| `event` | string | Y | Event identifier |\n| `userId` | int or null | N | Acting user ID |\n| `storeId` | int or null | N | Store context ID |\n| `resourceType` | string or null | N | Affected resource type |\n| `resourceId` | int or null | N | Affected resource ID |\n| `extra` | associative array | N | Additional key-value pairs to include in the log entry |\n\n**Behavior:**\n\n1. Write to the `audit` logging channel at the `info` level.\n2. Automatically include `timestamp` (ISO-8601), `ip` (from request), and `user_agent` (from request) in every entry.\n3. Merge any `extra` data into the log context.\n\n---\n\n### 8.5 Integration with Laravel Events\n\nThe audit logger hooks into Laravel's built-in authentication events and custom model observers.\n\n#### Auth Event Listeners\n\nRegister listeners (in `AppServiceProvider` or `EventServiceProvider`) for these Laravel events:\n\n| Laravel Event | Audit Event ID | Data Captured |\n|--------------|----------------|---------------|\n| `Illuminate\\Auth\\Events\\Login` | `auth.login` | user_id from event |\n| `Illuminate\\Auth\\Events\\Failed` | `auth.failed_login` | email from credentials (never password) |\n| `Illuminate\\Auth\\Events\\Logout` | `auth.logout` | user_id from event |\n\n#### Model Observers\n\nFor each auditable resource model, create an observer (or use a shared trait) that logs `created`, `updated`, and `deleted` events. The `updated` observer should capture the changed fields (dirty attributes) with their original and new values.\n\n---\n\n## 9. Security Checklist Summary\n\n| # | Control | Implementation Approach | Priority |\n|---|---------|------------------------|----------|\n| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |\n| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |\n| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |\n| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |\n| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |\n| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |\n","structuredPatch":[{"oldStart":614,"oldLines":7,"newStart":614,"newLines":7,"lines":[" | Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |"," | Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |"," | Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |","-| Webhook routes | `api`, `throttle:webhooks` | Incoming webhooks (Stripe, PayPal) - no auth |","+| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"f22ea2b0-7a54-4f44-b615-8db21e07ef49"}
{"parentUuid":"e423870a-06d4-4258-bc43-84e190facd28","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KxhGd4ZZqx4MQN1oyn2cWk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0184rfXZe88au9v9q5E25q93","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |","new_string":"| `PAYMENT_PROVIDER` | `mock` | Mock PSP - no external API keys needed |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3233,"cache_read_input_tokens":154408,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3233},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X54xovLoiiAbditgQY","type":"assistant","uuid":"c3e7c665-6593-4dbe-b5ab-f1eeb8d7bac4","timestamp":"2026-02-11T15:29:15.619Z"}
{"parentUuid":"c3e7c665-6593-4dbe-b5ab-f1eeb8d7bac4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_0184rfXZe88au9v9q5E25q93","toolUseID":"toolu_0184rfXZe88au9v9q5E25q93","timestamp":"2026-02-11T15:29:15.626Z","uuid":"332ad288-c83b-4f1e-a1be-32dbbae521a6"}
{"parentUuid":"c3e7c665-6593-4dbe-b5ab-f1eeb8d7bac4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0184rfXZe88au9v9q5E25q93","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"f88c7c21-c982-4e38-a717-87fd59820fc7","timestamp":"2026-02-11T15:29:15.626Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |","newString":"| `PAYMENT_PROVIDER` | `mock` | Mock PSP - no external API keys needed |","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |\n| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working. Tests payment method selection (credit card, PayPal, bank transfer), magic card declines, and bank transfer instructions. |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n---\n\n### Test 4.8: Can confirm bank transfer payment\n\n**Preconditions:** Logged in as admin, Order #1005 exists with payment_method=bank_transfer and financial_status=pending\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n5. Verify \"Pending\" financial status badge is displayed\n6. Verify \"Confirm payment\" button is visible\n7. Click \"Confirm payment\"\n\n**Expected Results:**\n- Page displays \"Payment confirmed\"\n- Financial status badge changes to \"Paid\"\n- \"Confirm payment\" button is no longer visible\n- No JavaScript errors\n\n---\n\n### Test 4.9: Shows fulfillment guard for unpaid order\n\n**Preconditions:** Logged in as admin, Order #1005 exists with financial_status=pending (before payment confirmation)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n\n**Expected Results:**\n- Page displays a warning message about payment required before fulfillment\n- \"Create fulfillment\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 4.10: Can mark fulfillment as shipped\n\n**Preconditions:** Logged in as admin, Order #1001 is paid and has been fulfilled (fulfillment created in test 4.5)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Create a fulfillment first (if not already done via test 4.5)\n6. Click \"Mark as shipped\" on the fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Shipped\"\n- No JavaScript errors\n\n---\n\n### Test 4.11: Can mark fulfillment as delivered\n\n**Preconditions:** Logged in as admin, Order #1001 has a shipped fulfillment\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Mark as delivered\" on the shipped fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Delivered\"\n- Order fulfillment status updates to \"Fulfilled\"\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout with credit card\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify \"Credit Card\" is pre-selected as payment method\n20. Enter card number: `4242 4242 4242 4242`\n21. Enter cardholder name: `Test Buyer`\n22. Enter expiry: `12/28`\n23. Enter CVC: `123`\n24. Verify total is 29.98 (24.99 + 4.99 shipping)\n25. Click \"Pay now\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n---\n\n### Test 9.9: Completes checkout with PayPal\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"PayPal\" radio button to select PayPal as payment method\n9. Click \"Pay with PayPal\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays \"PayPal\" in payment method section\n- No JavaScript errors\n\n---\n\n### Test 9.10: Completes checkout with bank transfer\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"Bank Transfer\" radio button to select Bank Transfer as payment method\n9. Click \"Place order\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays bank transfer instructions (IBAN, BIC, reference)\n- Confirmation page displays the order number as reference\n- No JavaScript errors\n\n---\n\n### Test 9.11: Shows error for declined credit card (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Verify \"Credit Card\" is selected\n9. Enter card number: `4000 0000 0000 0002` (magic decline card)\n10. Enter cardholder name: `Test Buyer`\n11. Enter expiry: `12/28`\n12. Enter CVC: `123`\n13. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"declined\"\n- Customer remains on the checkout page (NOT redirected to confirmation)\n- No JavaScript errors\n\n---\n\n### Test 9.12: Shows error for insufficient funds (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Enter card number: `4000 0000 0000 9995` (magic insufficient funds card)\n9. Enter cardholder name: `Test Buyer`\n10. Enter expiry: `12/28`\n11. Enter CVC: `123`\n12. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"insufficient\"\n- Customer remains on the checkout page\n- No JavaScript errors\n\n---\n\n### Test 9.13: Switches between payment method forms\n\n**Preconditions:** Product #1, reached Step 4 (payment) in checkout\n\n**Steps:**\n1. Complete checkout steps 1-3 (contact, address, shipping)\n2. Verify \"Credit Card\" is selected and card form fields are visible\n3. Click \"PayPal\" radio button\n4. Verify card form fields are hidden\n5. Verify \"Pay with PayPal\" button is visible\n6. Click \"Bank Transfer\" radio button\n7. Verify \"Place order\" button is visible\n8. Verify bank transfer information text is displayed\n\n**Expected Results:**\n- Payment form dynamically updates based on selected payment method\n- No JavaScript errors\n\n**Total: 13 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":65,"oldLines":8,"newStart":65,"newLines":7,"lines":[" | `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |"," | `DB_CONNECTION` | `sqlite` | Database driver |"," | `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |","-| `STRIPE_SECRET_KEY` | `sk_test_fake` | Fake Stripe key for test mode |","-| `STRIPE_WEBHOOK_SECRET` | `whsec_test_fake` | Fake webhook secret |","+| `PAYMENT_PROVIDER` | `mock` | Mock PSP - no external API keys needed |"," | `MAIL_MAILER` | `array` | Trap emails in memory |"," | `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"c3e7c665-6593-4dbe-b5ab-f1eeb8d7bac4"}
{"parentUuid":"f88c7c21-c982-4e38-a717-87fd59820fc7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01KxhGd4ZZqx4MQN1oyn2cWk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01B59qc3CtWZZo8RAd4LdoC5","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).","new_string":"5. **Payment testing:** All payments are processed in-process via the Mock PSP. For credit card tests, use the magic card number `4242 4242 4242 4242` for success, `4000 0000 0000 0002` for decline, and `4000 0000 0000 9995` for insufficient funds. No external API calls, webhooks, or redirects are involved."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3233,"cache_read_input_tokens":154408,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3233},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X54xovLoiiAbditgQY","type":"assistant","uuid":"61f27ef9-f2d3-4d7c-b7af-aceea18eb895","timestamp":"2026-02-11T15:29:19.177Z"}
{"parentUuid":"61f27ef9-f2d3-4d7c-b7af-aceea18eb895","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01B59qc3CtWZZo8RAd4LdoC5","toolUseID":"toolu_01B59qc3CtWZZo8RAd4LdoC5","timestamp":"2026-02-11T15:29:19.182Z","uuid":"eaf2c2a8-b650-4359-85b5-882bcaafee27"}
{"parentUuid":"61f27ef9-f2d3-4d7c-b7af-aceea18eb895","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01B59qc3CtWZZo8RAd4LdoC5","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"67e9bbce-fccf-42c5-b5cd-3bf5d50118c4","timestamp":"2026-02-11T15:29:19.181Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).","newString":"5. **Payment testing:** All payments are processed in-process via the Mock PSP. For credit card tests, use the magic card number `4242 4242 4242 4242` for success, `4000 0000 0000 0002` for decline, and `4000 0000 0000 9995` for insufficient funds. No external API calls, webhooks, or redirects are involved.","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `PAYMENT_PROVIDER` | `mock` | Mock PSP - no external API keys needed |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working. Tests payment method selection (credit card, PayPal, bank transfer), magic card declines, and bank transfer instructions. |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n---\n\n### Test 4.8: Can confirm bank transfer payment\n\n**Preconditions:** Logged in as admin, Order #1005 exists with payment_method=bank_transfer and financial_status=pending\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n5. Verify \"Pending\" financial status badge is displayed\n6. Verify \"Confirm payment\" button is visible\n7. Click \"Confirm payment\"\n\n**Expected Results:**\n- Page displays \"Payment confirmed\"\n- Financial status badge changes to \"Paid\"\n- \"Confirm payment\" button is no longer visible\n- No JavaScript errors\n\n---\n\n### Test 4.9: Shows fulfillment guard for unpaid order\n\n**Preconditions:** Logged in as admin, Order #1005 exists with financial_status=pending (before payment confirmation)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n\n**Expected Results:**\n- Page displays a warning message about payment required before fulfillment\n- \"Create fulfillment\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 4.10: Can mark fulfillment as shipped\n\n**Preconditions:** Logged in as admin, Order #1001 is paid and has been fulfilled (fulfillment created in test 4.5)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Create a fulfillment first (if not already done via test 4.5)\n6. Click \"Mark as shipped\" on the fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Shipped\"\n- No JavaScript errors\n\n---\n\n### Test 4.11: Can mark fulfillment as delivered\n\n**Preconditions:** Logged in as admin, Order #1001 has a shipped fulfillment\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Mark as delivered\" on the shipped fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Delivered\"\n- Order fulfillment status updates to \"Fulfilled\"\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout with credit card\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify \"Credit Card\" is pre-selected as payment method\n20. Enter card number: `4242 4242 4242 4242`\n21. Enter cardholder name: `Test Buyer`\n22. Enter expiry: `12/28`\n23. Enter CVC: `123`\n24. Verify total is 29.98 (24.99 + 4.99 shipping)\n25. Click \"Pay now\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n---\n\n### Test 9.9: Completes checkout with PayPal\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"PayPal\" radio button to select PayPal as payment method\n9. Click \"Pay with PayPal\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays \"PayPal\" in payment method section\n- No JavaScript errors\n\n---\n\n### Test 9.10: Completes checkout with bank transfer\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"Bank Transfer\" radio button to select Bank Transfer as payment method\n9. Click \"Place order\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays bank transfer instructions (IBAN, BIC, reference)\n- Confirmation page displays the order number as reference\n- No JavaScript errors\n\n---\n\n### Test 9.11: Shows error for declined credit card (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Verify \"Credit Card\" is selected\n9. Enter card number: `4000 0000 0000 0002` (magic decline card)\n10. Enter cardholder name: `Test Buyer`\n11. Enter expiry: `12/28`\n12. Enter CVC: `123`\n13. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"declined\"\n- Customer remains on the checkout page (NOT redirected to confirmation)\n- No JavaScript errors\n\n---\n\n### Test 9.12: Shows error for insufficient funds (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Enter card number: `4000 0000 0000 9995` (magic insufficient funds card)\n9. Enter cardholder name: `Test Buyer`\n10. Enter expiry: `12/28`\n11. Enter CVC: `123`\n12. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"insufficient\"\n- Customer remains on the checkout page\n- No JavaScript errors\n\n---\n\n### Test 9.13: Switches between payment method forms\n\n**Preconditions:** Product #1, reached Step 4 (payment) in checkout\n\n**Steps:**\n1. Complete checkout steps 1-3 (contact, address, shipping)\n2. Verify \"Credit Card\" is selected and card form fields are visible\n3. Click \"PayPal\" radio button\n4. Verify card form fields are hidden\n5. Verify \"Pay with PayPal\" button is visible\n6. Click \"Bank Transfer\" radio button\n7. Verify \"Place order\" button is visible\n8. Verify bank transfer information text is displayed\n\n**Expected Results:**\n- Payment form dynamically updates based on selected payment method\n- No JavaScript errors\n\n**Total: 13 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":3164,"oldLines":7,"newStart":3164,"newLines":7,"lines":[" "," 4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\"."," ","-5. **Payment testing:** For the checkout happy path, payment completion may require a test helper route or Stripe test mode integration. The coding agent should determine the best approach based on the payment implementation (mock webhook, test card, or bypass route).","+5. **Payment testing:** All payments are processed in-process via the Mock PSP. For credit card tests, use the magic card number `4242 4242 4242 4242` for success, `4000 0000 0000 0002` for decline, and `4000 0000 0000 9995` for insufficient funds. No external API calls, webhooks, or redirects are involved."," "," 6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"61f27ef9-f2d3-4d7c-b7af-aceea18eb895"}
{"parentUuid":"67e9bbce-fccf-42c5-b5cd-3bf5d50118c4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CX8jxDUnLYLkRqNn4em2Hr","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the Stripe/PayPal webhook verification section in 06-AUTH-AND-SECURITY.md."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1410,"cache_read_input_tokens":157641,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1410},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X5xZsS8Nbd6QiVCwLw","type":"assistant","uuid":"a0463c8d-7410-42d6-b717-5a2cd4c67958","timestamp":"2026-02-11T15:29:22.624Z"}
{"parentUuid":"a0463c8d-7410-42d6-b717-5a2cd4c67958","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CX8jxDUnLYLkRqNn4em2Hr","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BV1dMiq8fRLCXpfcaA1NLb","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","offset":968,"limit":60}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1410,"cache_read_input_tokens":157641,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1410},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X5xZsS8Nbd6QiVCwLw","type":"assistant","uuid":"ecc2d009-ca3b-4c6f-8bbd-46a7b82188f9","timestamp":"2026-02-11T15:29:23.344Z"}
{"parentUuid":"ecc2d009-ca3b-4c6f-8bbd-46a7b82188f9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01BV1dMiq8fRLCXpfcaA1NLb","toolUseID":"toolu_01BV1dMiq8fRLCXpfcaA1NLb","timestamp":"2026-02-11T15:29:23.347Z","uuid":"8d794a77-24a9-44ee-9f8b-c33875fcb2c1"}
{"parentUuid":"ecc2d009-ca3b-4c6f-8bbd-46a7b82188f9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BV1dMiq8fRLCXpfcaA1NLb","type":"tool_result","content":"   968→---\n   969→\n   970→### 7.2 PayPal Webhook Verification\n   971→\n   972→Controller: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`\n   973→\n   974→```mermaid\n   975→flowchart TD\n   976→    A[Receive POST request] --> B[Read raw body and headers]\n   977→    B --> C[Call PayPal verification API with webhook ID + headers + body]\n   978→    C --> D{verification_status == SUCCESS?}\n   979→    D -- No --> E[Return 400 Bad Request]\n   980→    D -- Yes --> F[Parse event type from body]\n   981→    F --> G{Route to handler}\n   982→    G --> H[PAYMENT.CAPTURE.COMPLETED]\n   983→    G --> I[PAYMENT.CAPTURE.REFUNDED]\n   984→    G --> J[Unknown event - ignore]\n   985→    H --> K[Return 200 OK]\n   986→    I --> K\n   987→    J --> K\n   988→```\n   989→\n   990→**Requirements:**\n   991→\n   992→1. Read the raw request body and all relevant PayPal headers.\n   993→2. Call PayPal's webhook verification API endpoint, passing the webhook ID, transmission headers, and request body.\n   994→3. Check the `verification_status` field in the PayPal response. Only proceed if it equals `SUCCESS`.\n   995→4. On verification failure: return 400.\n   996→5. Handled event types: `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED`. Unknown events are acknowledged (200) but ignored.\n   997→\n   998→---\n   999→\n  1000→### 7.3 Platform Webhooks (Outbound)\n  1001→\n  1002→Service: `App\\Services\\WebhookDispatcher`\n  1003→\n  1004→When the platform sends webhooks to external subscribers (third-party apps, integrations).\n  1005→\n  1006→```mermaid\n  1007→sequenceDiagram\n  1008→    participant App as Platform\n  1009→    participant Sub as Subscriber URL\n  1010→\n  1011→    App->>App: Serialize payload as JSON\n  1012→    App->>App: Generate Unix timestamp\n  1013→    App->>App: Concatenate: \"{timestamp}.{json_body}\"\n  1014→    App->>App: HMAC-SHA256(signing_secret, concatenated_message)\n  1015→    App->>Sub: POST to subscriber URL\n  1016→    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json\n  1017→    Sub-->>App: 200 OK (acknowledged)\n  1018→```\n  1019→\n  1020→**Requirements:**\n  1021→\n  1022→1. The payload is serialized as JSON.\n  1023→2. A Unix timestamp is generated (current time).\n  1024→3. The message to sign is: `\"{timestamp}.{json_body}\"` (timestamp, dot, JSON string).\n  1025→4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).\n  1026→5. The HTTP request to the subscriber includes three headers:\n  1027→   - `X-Platform-Signature`: the hex-encoded HMAC digest"}]},"uuid":"f2d3bc5b-f82d-4e1f-83a3-cf8267577659","timestamp":"2026-02-11T15:29:23.346Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"---\n\n### 7.2 PayPal Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw body and headers]\n    B --> C[Call PayPal verification API with webhook ID + headers + body]\n    C --> D{verification_status == SUCCESS?}\n    D -- No --> E[Return 400 Bad Request]\n    D -- Yes --> F[Parse event type from body]\n    F --> G{Route to handler}\n    G --> H[PAYMENT.CAPTURE.COMPLETED]\n    G --> I[PAYMENT.CAPTURE.REFUNDED]\n    G --> J[Unknown event - ignore]\n    H --> K[Return 200 OK]\n    I --> K\n    J --> K\n```\n\n**Requirements:**\n\n1. Read the raw request body and all relevant PayPal headers.\n2. Call PayPal's webhook verification API endpoint, passing the webhook ID, transmission headers, and request body.\n3. Check the `verification_status` field in the PayPal response. Only proceed if it equals `SUCCESS`.\n4. On verification failure: return 400.\n5. Handled event types: `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.3 Platform Webhooks (Outbound)\n\nService: `App\\Services\\WebhookDispatcher`\n\nWhen the platform sends webhooks to external subscribers (third-party apps, integrations).\n\n```mermaid\nsequenceDiagram\n    participant App as Platform\n    participant Sub as Subscriber URL\n\n    App->>App: Serialize payload as JSON\n    App->>App: Generate Unix timestamp\n    App->>App: Concatenate: \"{timestamp}.{json_body}\"\n    App->>App: HMAC-SHA256(signing_secret, concatenated_message)\n    App->>Sub: POST to subscriber URL\n    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json\n    Sub-->>App: 200 OK (acknowledged)\n```\n\n**Requirements:**\n\n1. The payload is serialized as JSON.\n2. A Unix timestamp is generated (current time).\n3. The message to sign is: `\"{timestamp}.{json_body}\"` (timestamp, dot, JSON string).\n4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).\n5. The HTTP request to the subscriber includes three headers:\n   - `X-Platform-Signature`: the hex-encoded HMAC digest","numLines":60,"startLine":968,"totalLines":1170}},"sourceToolAssistantUUID":"ecc2d009-ca3b-4c6f-8bbd-46a7b82188f9"}
{"parentUuid":"f2d3bc5b-f82d-4e1f-83a3-cf8267577659","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01PRsCS5WSCrGSgPxRSEFZcg","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016Vp2yR1nn3DGwHgP7phzES","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","old_string":"## 7. Webhook Signature Verification\n\n### 7.1 Stripe Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\StripeWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw request body]\n    B --> C[Get Stripe-Signature header]\n    C --> D[Get STRIPE_WEBHOOK_SECRET from config]\n    D --> E{Verify signature using Stripe SDK}\n    E -- Invalid --> F[Return 400 Bad Request]\n    E -- Valid --> G[Parse event type]\n    G --> H{Route to handler}\n    H --> I[checkout.session.completed]\n    H --> J[payment_intent.succeeded]\n    H --> K[charge.refunded]\n    H --> L[Unknown event - ignore]\n    I --> M[Return 200 OK]\n    J --> M\n    K --> M\n    L --> M\n```\n\n**Requirements:**\n\n1. Read the raw request body (not parsed JSON) because signature verification depends on the exact byte sequence.\n2. Retrieve the `Stripe-Signature` header from the incoming request.\n3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.\n4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.\n5. On signature verification failure: return 400 immediately.\n6. On success: dispatch to the appropriate handler based on event type, then return 200.\n7. Handled event types: `checkout.session.completed`, `payment_intent.succeeded`, `charge.refunded`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.2 PayPal Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw body and headers]\n    B --> C[Call PayPal verification API with webhook ID + headers + body]\n    C --> D{verification_status == SUCCESS?}\n    D -- No --> E[Return 400 Bad Request]\n    D -- Yes --> F[Parse event type from body]\n    F --> G{Route to handler}\n    G --> H[PAYMENT.CAPTURE.COMPLETED]\n    G --> I[PAYMENT.CAPTURE.REFUNDED]\n    G --> J[Unknown event - ignore]\n    H --> K[Return 200 OK]\n    I --> K\n    J --> K\n```\n\n**Requirements:**\n\n1. Read the raw request body and all relevant PayPal headers.\n2. Call PayPal's webhook verification API endpoint, passing the webhook ID, transmission headers, and request body.\n3. Check the `verification_status` field in the PayPal response. Only proceed if it equals `SUCCESS`.\n4. On verification failure: return 400.\n5. Handled event types: `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.3 Platform Webhooks (Outbound)","new_string":"## 7. Webhook Signature Verification\n\n### 7.1 Inbound Payment Webhooks\n\n> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).\n\n---\n\n### 7.2 Platform Webhooks (Outbound)"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1019,"cache_read_input_tokens":159051,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1019},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X6GPBaDKwpMB9mXtKT","type":"assistant","uuid":"faf00f95-2b0e-4454-8f75-f131ca6258de","timestamp":"2026-02-11T15:29:34.829Z"}
{"parentUuid":"faf00f95-2b0e-4454-8f75-f131ca6258de","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_016Vp2yR1nn3DGwHgP7phzES","toolUseID":"toolu_016Vp2yR1nn3DGwHgP7phzES","timestamp":"2026-02-11T15:29:34.835Z","uuid":"edbfec9c-325a-4b19-9e22-6451fb82851e"}
{"parentUuid":"faf00f95-2b0e-4454-8f75-f131ca6258de","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016Vp2yR1nn3DGwHgP7phzES","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md has been updated successfully."}]},"uuid":"bbd797c5-3769-47ca-9a23-effd6b4e8757","timestamp":"2026-02-11T15:29:34.834Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","oldString":"## 7. Webhook Signature Verification\n\n### 7.1 Stripe Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\StripeWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw request body]\n    B --> C[Get Stripe-Signature header]\n    C --> D[Get STRIPE_WEBHOOK_SECRET from config]\n    D --> E{Verify signature using Stripe SDK}\n    E -- Invalid --> F[Return 400 Bad Request]\n    E -- Valid --> G[Parse event type]\n    G --> H{Route to handler}\n    H --> I[checkout.session.completed]\n    H --> J[payment_intent.succeeded]\n    H --> K[charge.refunded]\n    H --> L[Unknown event - ignore]\n    I --> M[Return 200 OK]\n    J --> M\n    K --> M\n    L --> M\n```\n\n**Requirements:**\n\n1. Read the raw request body (not parsed JSON) because signature verification depends on the exact byte sequence.\n2. Retrieve the `Stripe-Signature` header from the incoming request.\n3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.\n4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.\n5. On signature verification failure: return 400 immediately.\n6. On success: dispatch to the appropriate handler based on event type, then return 200.\n7. Handled event types: `checkout.session.completed`, `payment_intent.succeeded`, `charge.refunded`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.2 PayPal Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw body and headers]\n    B --> C[Call PayPal verification API with webhook ID + headers + body]\n    C --> D{verification_status == SUCCESS?}\n    D -- No --> E[Return 400 Bad Request]\n    D -- Yes --> F[Parse event type from body]\n    F --> G{Route to handler}\n    G --> H[PAYMENT.CAPTURE.COMPLETED]\n    G --> I[PAYMENT.CAPTURE.REFUNDED]\n    G --> J[Unknown event - ignore]\n    H --> K[Return 200 OK]\n    I --> K\n    J --> K\n```\n\n**Requirements:**\n\n1. Read the raw request body and all relevant PayPal headers.\n2. Call PayPal's webhook verification API endpoint, passing the webhook ID, transmission headers, and request body.\n3. Check the `verification_status` field in the PayPal response. Only proceed if it equals `SUCCESS`.\n4. On verification failure: return 400.\n5. Handled event types: `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.3 Platform Webhooks (Outbound)","newString":"## 7. Webhook Signature Verification\n\n### 7.1 Inbound Payment Webhooks\n\n> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).\n\n---\n\n### 7.2 Platform Webhooks (Outbound)","originalFile":"# 06 - Authentication and Security Specification\n\n> Shopify-clone e-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite\n\n---\n\n## 1. Authentication\n\nThe platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.\n\n### 1.1 Admin Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `web` (default Laravel guard) |\n| Provider name | `users` (Eloquent driver) |\n| Model | `App\\Models\\User` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /admin/login` |\n| Login form | Livewire component `Admin\\Auth\\Login` |\n\n#### Admin Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (web)\n    participant DB as Database\n    participant S as Session\n\n    B->>LW: Submit email + password\n    LW->>LW: Validate input (email required, password required)\n    LW->>LW: Check rate limiter (5/min per IP)\n    alt Rate limit exceeded\n        LW-->>B: Flash \"Too many attempts. Try again in X seconds.\"\n    end\n    LW->>Auth: attempt(credentials, remember)\n    Auth->>DB: SELECT from users WHERE email = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Credentials valid\n        Auth->>S: Regenerate session ID (prevent fixation)\n        Auth-->>LW: Success\n        LW-->>B: Redirect to /admin (dashboard)\n    else Credentials invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\" (generic, never reveal which field)\n    end\n```\n\n**Requirements:**\n\n- The login form collects `email` and `password`.\n- On successful login, the session MUST be regenerated to prevent session fixation attacks.\n- On failed login, only the generic message \"Invalid credentials\" is shown. The system must not indicate whether the email or password was wrong.\n- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.\n- After login: redirect to `/admin` (the dashboard).\n\n#### Remember Me\n\n- An optional \"Remember me\" checkbox on the login form.\n- When enabled, Laravel sets a long-lived remember token cookie.\n- The `remember_token` column on the `users` table stores the hashed token.\n- Cookie name follows Laravel default convention: `remember_web_{hash}`.\n\n#### Admin Password Reset Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant App as Application\n    participant DB as Database\n    participant Mail as Mail Service\n\n    B->>App: GET /admin/forgot-password\n    App-->>B: Render forgot-password form\n    B->>App: POST /admin/forgot-password (email)\n    App->>DB: Look up user by email\n    alt User exists\n        App->>DB: Store reset token in password_reset_tokens table\n        App->>Mail: Send reset link email\n    end\n    App-->>B: \"If that email exists, we sent a reset link.\" (generic)\n    B->>App: GET /admin/reset-password/{token}\n    App-->>B: Render reset-password form\n    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)\n    App->>DB: Validate token (exists, not expired, matches email)\n    alt Valid token\n        App->>DB: Update user password (bcrypt hash)\n        App->>DB: Delete used token\n        App-->>B: Redirect to /admin/login with success flash\n    else Invalid/expired token\n        App-->>B: Error message\n    end\n```\n\n**Requirements:**\n\n- Uses Laravel's built-in password broker (`users` broker).\n- Reset tokens stored in the `password_reset_tokens` table.\n- Token expiration: 60 minutes (configurable in `config/auth.php` under `passwords.users.expire`).\n- Throttle: one reset email per 60 seconds per email address.\n- The \"forgot password\" response must be generic (do not reveal whether email exists).\n\n#### Admin Logout\n\n- Route: `POST /admin/logout` (via Livewire action or standard route).\n- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.\n- The response must not cache.\n\n#### Session Lifetime\n\n| Setting | Value |\n|---------|-------|\n| Lifetime | 120 minutes (configurable via `SESSION_LIFETIME` env var) |\n| Expire on close | No (session persists until lifetime expires) |\n\n---\n\n### 1.2 Customer Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `customer` (custom guard) |\n| Provider name | `customers` (Eloquent driver) |\n| Model | `App\\Models\\Customer` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /account/login` |\n| Registration route | `GET /account/register` |\n\n#### Customer Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (customer)\n    participant Store as ResolveStore Middleware\n    participant DB as Database\n\n    Note over Store: Store already resolved from hostname\n    B->>LW: Submit email + password\n    LW->>LW: Validate input\n    LW->>LW: Check rate limiter (5/min per IP)\n    LW->>Auth: attempt(credentials + store_id, remember)\n    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Valid\n        Auth-->>LW: Success (regenerate session)\n        LW-->>B: Redirect to /account (or intended URL)\n    else Invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\"\n    end\n```\n\n**Requirements:**\n\n- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.\n- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.\n- The same email address can exist in different stores (multi-tenant isolation).\n- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.\n- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).\n\n#### Customer Registration Flow\n\n```mermaid\nflowchart TD\n    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]\n    B --> C{Validate via RegisterCustomerRequest}\n    C -- Invalid --> D[Show validation errors]\n    C -- Valid --> E[Create Customer record scoped to current store]\n    E --> F[Auto-login via customer guard]\n    F --> G[Redirect to /account]\n```\n\n**Registration fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|:--------:|------------|\n| name | string | Y | max:255 |\n| email | string | Y | valid email, unique per store (composite unique on email + store_id) |\n| password | string | Y | min:8, confirmed |\n| password_confirmation | string | Y | must match password |\n| marketing_opt_in | boolean | N | defaults to false |\n\n#### Customer Store Scoping\n\n- The `customers` auth provider requires a custom `CustomerUserProvider` class.\n- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.\n- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).\n- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.\n\n#### Customer Password Reset\n\n- Uses a separate password broker: `customers` broker.\n- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).\n- Token records include `store_id` to scope resets to the correct store.\n- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.\n- Token expiration: 60 minutes.\n- Throttle: one reset email per 60 seconds.\n- Generic response on the \"forgot password\" form (do not reveal whether email exists in the store).\n\n---\n\n### 1.3 API Authentication (Sanctum)\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `sanctum` |\n| Provider | `users` (admin users only) |\n| Token type | Personal access tokens |\n| Token prefix | `shop_` |\n| Hashing | SHA-256 |\n| Default expiration | 1 year |\n| Storage | `personal_access_tokens` table |\n\n#### Token Lifecycle\n\n```mermaid\nstateDiagram-v2\n    [*] --> Created: Admin creates token in Developers section\n    Created --> Active: Plain-text shown once, hash stored\n    Active --> InUse: Token sent as Bearer header\n    InUse --> Active: Request processed\n    Active --> Revoked: Admin revokes token\n    Active --> Expired: Expiration date reached\n    Revoked --> [*]: Token row deleted\n    Expired --> [*]: Token row deleted (cleanup job)\n```\n\n**Requirements:**\n\n- Tokens are created in the admin panel, under the Developers section.\n- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).\n- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.\n- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.\n- Sanctum middleware validates the hash and resolves the associated user.\n- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).\n- Revoking a token deletes its row from the `personal_access_tokens` table.\n\n#### Token Abilities (Scopes)\n\n| Ability | Description |\n|---------|-------------|\n| `read-products` | List and view products |\n| `write-products` | Create, update, delete products |\n| `read-orders` | List and view orders |\n| `write-orders` | Update orders, create fulfillments |\n| `read-customers` | List and view customers |\n| `write-customers` | Update customers |\n| `read-collections` | List and view collections |\n| `write-collections` | Create, update, delete collections |\n| `read-discounts` | List and view discounts |\n| `write-discounts` | Create, update, delete discounts |\n| `read-analytics` | View analytics data |\n| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |\n| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |\n| `read-themes` | View themes and theme files |\n| `write-themes` | Create, update, delete, publish themes |\n| `read-content` | View pages and navigation menus |\n| `write-content` | Create, update, delete pages and navigation items |\n| `manage-platform` | Platform-level management (super-admin only) |\n\nEach API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.\n\n---\n\n### 1.4 Guard Configuration\n\nFile to configure: `config/auth.php`\n\n#### Guards Data Dictionary\n\n| Guard Name | Driver | Provider | Purpose |\n|------------|--------|----------|---------|\n| `web` | session | `users` | Admin panel session auth (default guard) |\n| `customer` | session | `customers` | Storefront customer session auth |\n| `sanctum` | sanctum | `users` | Admin REST API token auth |\n\n#### Providers Data Dictionary\n\n| Provider Name | Driver | Model | Purpose |\n|---------------|--------|-------|---------|\n| `users` | eloquent | `App\\Models\\User` | Admin users |\n| `customers` | eloquent | `App\\Models\\Customer` | Storefront customers (store-scoped via custom provider) |\n\n#### Password Brokers Data Dictionary\n\n| Broker Name | Provider | Token Table | Expiry | Throttle |\n|-------------|----------|-------------|--------|----------|\n| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |\n| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |\n\n**Defaults:** The default guard is `web` and the default password broker is `users`.\n\n---\n\n## 2. Authorization\n\n### 2.1 Role Enum\n\nFile: `app/Enums/StoreUserRole.php`\n\nA backed string enum with four values:\n\n| Case | Stored Value | Description |\n|------|-------------|-------------|\n| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |\n| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |\n| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |\n| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |\n\n**Constraints:**\n\n- Each store must have exactly one Owner.\n- Ownership transfer is a dedicated action (not a simple role change).\n- A User can hold different roles in different stores (via the `store_users` pivot table).\n\n---\n\n### 2.2 Permission Matrix\n\nThe `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.\n\n| Resource | Action | Owner | Admin | Staff | Support |\n|----------|--------|:-----:|:-----:|:-----:|:-------:|\n| Store Settings | View | Y | Y | N | N |\n| Store Settings | Update | Y | Y | N | N |\n| Store | Delete | Y | N | N | N |\n| Products | List / View | Y | Y | Y | Y |\n| Products | Create | Y | Y | Y | N |\n| Products | Update | Y | Y | Y | N |\n| Products | Delete / Archive | Y | Y | N | N |\n| Products | Restore | Y | Y | N | N |\n| Collections | List / View | Y | Y | Y | Y |\n| Collections | Create | Y | Y | Y | N |\n| Collections | Update | Y | Y | Y | N |\n| Collections | Delete | Y | Y | N | N |\n| Orders | List / View | Y | Y | Y | Y |\n| Orders | Update | Y | Y | Y | N |\n| Orders | Cancel | Y | Y | N | N |\n| Fulfillments | Create | Y | Y | Y | N |\n| Fulfillments | Update / Cancel | Y | Y | Y | N |\n| Refunds | Create | Y | Y | N | N |\n| Customers | List / View | Y | Y | Y | Y |\n| Customers | Update | Y | Y | Y | N |\n| Discounts | List / View | Y | Y | Y | Y |\n| Discounts | Create | Y | Y | Y | N |\n| Discounts | Update | Y | Y | Y | N |\n| Discounts | Delete | Y | Y | N | N |\n| Themes | List / View | Y | Y | N | N |\n| Themes | Create / Upload | Y | Y | N | N |\n| Themes | Customize | Y | Y | N | N |\n| Themes | Publish | Y | Y | N | N |\n| Themes | Delete | Y | Y | N | N |\n| Pages | List / View | Y | Y | Y | N |\n| Pages | Create | Y | Y | Y | N |\n| Pages | Update | Y | Y | Y | N |\n| Pages | Delete | Y | Y | N | N |\n| Navigation | List / View | Y | Y | Y | N |\n| Navigation | Manage | Y | Y | N | N |\n| Apps | List / View | Y | Y | N | N |\n| Apps | Install / Remove | Y | Y | N | N |\n| Analytics | View | Y | Y | Y | N |\n| Developers / API | Manage | Y | Y | N | N |\n| Staff | List / View | Y | Y | N | N |\n| Staff | Invite | Y | Y | N | N |\n| Staff | Update Role | Y | Y | N | N |\n| Staff | Remove | Y | Y | N | N |\n| Shipping Zones | Manage | Y | Y | N | N |\n| Tax Settings | Manage | Y | Y | N | N |\n| Search Settings | Manage | Y | Y | N | N |\n\n#### Role Group Shorthand\n\nThe permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:\n\n| Group Name | Roles Included | Used For |\n|------------|---------------|----------|\n| OwnerOnly | Owner | Store deletion, ownership transfer |\n| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |\n| OwnerAdminOrStaff | Owner, Admin, Staff | Create/update products, collections, discounts, pages, fulfillments, order updates |\n| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |\n\n---\n\n### 2.3 Role Checking Trait\n\nFile: `app/Traits/ChecksStoreRole.php`\n\nA reusable trait included in all policies and used by gate definitions. Must provide the following methods:\n\n| Method | Parameters | Returns | Description |\n|--------|-----------|---------|-------------|\n| `getStoreRole` | User, store_id (int) | StoreUserRole or null | Looks up the user's role for the given store via the `store_users` relationship. Returns null if the user has no role in that store. |\n| `hasRole` | User, store_id (int), roles (array of StoreUserRole) | bool | Returns true if the user's role for the store is in the provided list. Returns false if user has no role. |\n| `isOwnerOrAdmin` | User, store_id (int) | bool | Shorthand: checks if role is Owner or Admin. |\n| `isOwnerAdminOrStaff` | User, store_id (int) | bool | Shorthand: checks if role is Owner, Admin, or Staff. |\n| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |\n\n**Store ID resolution:** When the policy method receives a model instance (e.g., Product), the store_id comes from the model's `store_id` attribute. When the policy method does not receive a model (e.g., `viewAny`, `create`), the store_id is resolved from the container-bound `current_store`.\n\n---\n\n### 2.4 Laravel Policies\n\nAll policies use the `ChecksStoreRole` trait. Laravel 12 auto-discovers policies by convention (`App\\Policies\\{Model}Policy`), so no manual registration is needed.\n\nEvery policy method follows this pattern:\n1. Determine the `store_id` (from the model if available, otherwise from the resolved current store).\n2. Use the appropriate role-checking helper method.\n3. Return a boolean.\n\n#### ProductPolicy\n\nFile: `app/Policies/ProductPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Product)` | Yes | AnyRole | product.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Product)` | Yes | OwnerAdminOrStaff | product.store_id |\n| `delete(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `archive(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `restore(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n\n#### OrderPolicy\n\nFile: `app/Policies/OrderPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Order)` | Yes | AnyRole | order.store_id |\n| `update(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `cancel(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n| `createFulfillment(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `createRefund(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n\n#### CollectionPolicy\n\nFile: `app/Policies/CollectionPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Collection)` | Yes | AnyRole | collection.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Collection)` | Yes | OwnerAdminOrStaff | collection.store_id |\n| `delete(User, Collection)` | Yes | OwnerOrAdmin | collection.store_id |\n\n#### DiscountPolicy\n\nFile: `app/Policies/DiscountPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Discount)` | Yes | AnyRole | discount.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Discount)` | Yes | OwnerAdminOrStaff | discount.store_id |\n| `delete(User, Discount)` | Yes | OwnerOrAdmin | discount.store_id |\n\n#### CustomerPolicy\n\nFile: `app/Policies/CustomerPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Customer)` | Yes | AnyRole | customer.store_id |\n| `update(User, Customer)` | Yes | OwnerAdminOrStaff | customer.store_id |\n\n#### ThemePolicy\n\nFile: `app/Policies/ThemePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerOrAdmin | current_store |\n| `view(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `create(User)` | No | OwnerOrAdmin | current_store |\n| `update(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `delete(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `publish(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n\n#### PagePolicy\n\nFile: `app/Policies/PagePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `view(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `delete(User, Page)` | Yes | OwnerOrAdmin | page.store_id |\n\n#### StorePolicy\n\nFile: `app/Policies/StorePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `updateSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `delete(User, Store)` | Yes | OwnerOnly | store.id |\n\n#### FulfillmentPolicy\n\nFile: `app/Policies/FulfillmentPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerAdminOrStaff | order.store_id |\n| `update(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n| `cancel(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n\nNote: `create` receives the parent Order, not a Fulfillment, since the Fulfillment does not exist yet. The `update` and `cancel` methods navigate through the Fulfillment's order relationship to get the store_id.\n\n#### RefundPolicy\n\nFile: `app/Policies/RefundPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerOrAdmin | order.store_id |\n\nNote: `create` receives the parent Order for the same reason as FulfillmentPolicy.\n\n#### NavigationMenuPolicy\n\nFile: `app/Policies/NavigationMenuPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `manage(User)` | No | OwnerOrAdmin | current_store |\n\n---\n\n### 2.5 Gates (Non-Model Operations)\n\nGates are registered in `AppServiceProvider` (or a dedicated `AuthServiceProvider`). Each gate resolves the current store from the container and checks the user's role via the `store_users` relationship.\n\n| Gate Name | Required Roles | Purpose |\n|-----------|---------------|---------|\n| `manage-store-settings` | Owner, Admin | Access store settings pages |\n| `manage-staff` | Owner, Admin | Invite, update, remove staff members |\n| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |\n| `view-analytics` | Owner, Admin, Staff | View analytics dashboard and reports |\n| `manage-shipping` | Owner, Admin | Manage shipping zones, rates, profiles |\n| `manage-taxes` | Owner, Admin | Manage tax settings and rates |\n| `manage-search-settings` | Owner, Admin | Configure search weights and synonyms |\n| `manage-navigation` | Owner, Admin | Create/edit/delete navigation menus |\n| `manage-apps` | Owner, Admin | Install and remove third-party apps |\n\n**Gate resolution pattern (all gates follow this):**\n\n1. Resolve the current store from the container\n2. Look up the user's `store_users` record for that store\n3. If no record exists, return false\n4. If the user's role is in the gate's required roles list, return true\n5. Otherwise return false\n\n---\n\n### 2.6 Policy and Gate Usage Patterns\n\n#### In Livewire Components\n\n- List views: call `authorize('viewAny', ModelClass)` in the `mount()` lifecycle method.\n- Action methods (delete, update, etc.): call `authorize('action', $modelInstance)` at the start of the action, before performing any mutation.\n- If authorization fails, Laravel throws `AuthorizationException`, which renders as 403.\n\n#### In API Controllers\n\n- Use `$this->authorize('action', $model)` at the start of each controller method.\n- Alternatively, use `can` middleware on routes for simpler checks.\n- Form Requests also implement `authorize()` to check the relevant policy or gate before validation runs.\n\n#### In Blade Templates\n\n- Use `@can('gate-name')` / `@cannot` directives to conditionally show/hide navigation items and action buttons.\n- This is purely cosmetic (UI hiding) - the actual authorization check happens server-side in policies/gates.\n\n---\n\n## 3. Middleware Stack\n\n### 3.1 Middleware Registration\n\nFile: `bootstrap/app.php`\n\nThree custom middleware aliases must be registered:\n\n| Alias | Class | Purpose |\n|-------|-------|---------|\n| `store.resolve` | `App\\Http\\Middleware\\ResolveStore` | Resolves current store from hostname or session |\n| `role.check` | `App\\Http\\Middleware\\CheckStoreRole` | Checks user has required role for current store |\n| `auth:customer` | `App\\Http\\Middleware\\CustomerAuthenticate` | Ensures customer is authenticated via customer guard |\n\n---\n\n### 3.2 Route Middleware Groups\n\n| Route Group | Middleware Stack | Description |\n|-------------|-----------------|-------------|\n| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |\n\n---\n\n### 3.3 Custom Middleware Specifications\n\n#### ResolveStore\n\nFile: `app/Http/Middleware/ResolveStore.php`\n\n**Purpose:** Resolves the current store from the request hostname and binds it to the service container.\n\n```mermaid\nflowchart TD\n    A[Extract hostname from request] --> B{Route type?}\n    B -- Storefront --> C{Match hostname in store_domains table?}\n    C -- Yes --> F[Store found]\n    C -- No --> E[Abort 404: Store not found]\n    F --> G{Store status == suspended?}\n    G -- Yes --> H[Abort 503: Store unavailable]\n    G -- No --> I[Bind Store to container as 'current_store']\n    I --> J[Share Store with all views as 'currentStore']\n    J --> K[Continue to next middleware]\n    B -- Admin --> L[Read store_id from session 'current_store_id']\n    L --> M{User has store_users record?}\n    M -- No --> N[Abort 403]\n    M -- Yes --> I\n```\n\n**Requirements:**\n\nStorefront requests: Resolve store from the `store_domains` table by matching the request hostname against `store_domains.hostname`. Cache the hostname-to-store mapping using file cache (tag: `store-domains`).\n\nAdmin requests: Store is determined by session key `current_store_id`, set after login or via the store-switcher in the admin panel. Admin routes do NOT use hostname resolution. The `admin.acme-fashion.test` domain in seeders routes requests to the admin panel, but the actual store context within the admin is determined by the session `current_store_id` after login.\n\n**Shared steps (after store is resolved):**\n\n1. If the store's `status` column is `'suspended'`: abort with HTTP 503 and message \"This store is currently unavailable.\" (For admin routes: abort 403 for mutations instead.)\n2. Bind the resolved Store model instance to the service container with the key `'current_store'`.\n3. Share the store with all Blade views under the variable name `currentStore`.\n4. Allow the request to proceed.\n\n#### CheckStoreRole\n\nFile: `app/Http/Middleware/CheckStoreRole.php`\n\n**Purpose:** Verifies the authenticated user has one of the specified roles for the current store.\n\n**Route usage:** Applied as `role.check:owner,admin` or `role.check:owner,admin,staff` (comma-separated role values passed as variadic parameters).\n\n```mermaid\nflowchart TD\n    A[Resolve current_store from container] --> B[Get authenticated user]\n    B --> C[Look up store_users record for user + store]\n    C --> D{Record exists?}\n    D -- No --> E[Abort 403: No access to this store]\n    D -- Yes --> F[Parse allowed roles from middleware parameters]\n    F --> G{User's role in allowed list?}\n    G -- No --> H[Abort 403: Insufficient permissions]\n    G -- Yes --> I[Attach store_user to request attributes]\n    I --> J[Continue to next middleware]\n```\n\n**Requirements:**\n\n1. Resolve the current store from the container.\n2. Get the authenticated user from the request.\n3. Query the user's `store_users` relationship filtered by `store_id`.\n4. If no record exists: abort 403 with message \"You do not have access to this store.\"\n5. Convert the middleware parameter strings to `StoreUserRole` enum instances.\n6. If the user's role is not in the allowed list: abort 403 with message \"Insufficient permissions.\"\n7. Store the resolved `StoreUser` pivot record on the request attributes (key: `store_user`) for downstream use.\n8. Allow the request to proceed.\n\n#### CustomerAuthenticate\n\nFile: `app/Http/Middleware/CustomerAuthenticate.php`\n\n**Purpose:** Ensures the request is authenticated via the `customer` guard. Redirects unauthenticated customers to login.\n\n```mermaid\nflowchart TD\n    A[Check customer guard authentication] --> B{Authenticated?}\n    B -- Yes --> C[Continue to next middleware]\n    B -- No --> D[Store current URL as intended redirect]\n    D --> E[Redirect to /account/login]\n```\n\n**Requirements:**\n\n1. Check if `Auth::guard('customer')` reports the user as authenticated.\n2. If authenticated: allow the request to proceed.\n3. If not authenticated: save the current URL in the session as the intended URL (so the customer returns there after login), then redirect to the storefront login route (`/account/login`).\n\n---\n\n## 4. Security Controls\n\n### 4.1 CSRF Protection\n\n| Context | Mechanism |\n|---------|-----------|\n| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |\n| Livewire actions | Livewire handles CSRF verification automatically on every request |\n| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |\n| SPA clients | `GET /sanctum/csrf-cookie` sets XSRF-TOKEN cookie for session-based API calls |\n\n**Requirements:**\n\n- CSRF verification is enforced globally on all `web` middleware group routes.\n- No routes within the `web` group may be excluded from CSRF verification.\n- API routes (under `api` middleware group) are inherently exempt since they use token auth.\n\n---\n\n### 4.2 Rate Limiting\n\nAll rate limiters are defined in `bootstrap/app.php` (or `AppServiceProvider::boot()`).\n\n| Limiter Name | Limit | Keyed By | Applied To |\n|-------------|-------|----------|------------|\n| `login` | 5 per minute | IP address | Admin login, customer login |\n| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |\n| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |\n| `checkout` | 10 per minute | Session ID | Checkout-related operations |\n| `search` | 30 per minute | IP address | Search queries |\n| `analytics` | 60 per minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |\n\n**Rate limit response headers** (returned on all API responses by Laravel default):\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429 responses) |\n\nWhen a rate limit is exceeded, the response is HTTP 429 Too Many Requests.\n\n---\n\n### 4.3 Encryption at Rest\n\nEncryption algorithm: AES-256-CBC with MAC verification, keyed by the application's `APP_KEY`.\n\n**Fields requiring encrypted storage (using Laravel's `encrypted` cast):**\n\n| Model | Column | Cast Type | Purpose |\n|-------|--------|-----------|---------|\n| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |\n| WebhookSubscription | `signing_secret_encrypted` | `encrypted` | Per-subscription signing secrets for outbound webhooks |\n| Payment | `raw_json` | `encrypted:array` | Full payment gateway response (may contain sensitive card/account data) |\n\n**Requirements:**\n\n- These columns are declared with the `encrypted` (or `encrypted:array`) cast in the model's `casts()` method.\n- Data is automatically encrypted before writing to the database and decrypted when reading.\n- The encrypted columns store opaque ciphertext (type TEXT in SQLite). They cannot be queried with WHERE clauses at the database level.\n\n---\n\n### 4.4 Input Validation\n\nAll write endpoints must use dedicated Form Request classes. Validation rules use string syntax per project convention.\n\n#### Form Request Inventory\n\n| Form Request Class | Used By | Key Validation Rules |\n|-------------------|---------|---------------------|\n| `StoreProductRequest` | Create product | `title`: required, string, max:255. `description_html`: nullable, string. `status`: required, in:active,draft,archived. `vendor`: nullable, string. Price fields: required, integer, min:0. |\n| `UpdateProductRequest` | Update product | Same fields as StoreProductRequest but all optional (sometimes rule). |\n| `StoreCollectionRequest` | Create collection | `title`: required, string, max:255. `type`: required, in:manual,automated. `rules_json`: required_if:type,automated, valid JSON. |\n| `UpdateCollectionRequest` | Update collection | Same as StoreCollectionRequest, all optional. |\n| `StoreDiscountRequest` | Create discount | `type`: required, in:code,automatic. `code`: required_if:type,code, string, max:50, unique per store (case-insensitive). `value_type`: required, in:fixed,percent,free_shipping. `value_amount`: required_unless:value_type,free_shipping, integer, min:0; when value_type is `percent`, max:100. `minimum_order_amount`: nullable, integer, min:0. `usage_limit`: nullable, integer, min:1. `starts_at`: nullable, date, ISO-8601. `ends_at`: nullable, date, after:starts_at. |\n| `UpdateDiscountRequest` | Update discount | Same as StoreDiscountRequest, code unique ignoring self. |\n| `SetCheckoutAddressRequest` | Set shipping/billing address | `first_name`, `last_name`, `address1`, `city`, `country_code`: required. `province_code`: required when applicable. `postal_code`: required. `phone`: nullable. |\n| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |\n| `CreateFulfillmentRequest` | Create fulfillment | `line_item_ids`: required, array of existing line item IDs. `tracking_company`: nullable, string. `tracking_number`: nullable, string. `tracking_url`: nullable, valid URL. |\n| `CreateRefundRequest` | Create refund | `line_items`: required, array with `id` and `quantity` per item. `reason`: required, string. `amount`: required, integer, min:0, max:original payment amount. |\n| `UpdateStoreSettingsRequest` | Update store settings | `name`: required, string. `default_currency`: in allowed currencies. `default_locale`: in allowed locales. `timezone`: valid timezone string. |\n| `InviteStaffRequest` | Invite staff member | `email`: required, valid email. `role`: required, in:admin,staff,support (not owner). |\n| `RegisterCustomerRequest` | Customer registration | `name`: required. `email`: required, email, unique per store (composite). `password`: required, min:8, confirmed. |\n| `LoginRequest` | Admin/customer login | `email`: required, email. `password`: required, string. |\n\n**Authorization in Form Requests:**\n\nEach Form Request's `authorize()` method must check the relevant policy or gate. For example, `StoreProductRequest::authorize()` checks whether the user can create a Product. If authorization fails, the request is rejected with 403 before validation even runs.\n\n---\n\n### 4.5 XSS Prevention\n\n| Context | Strategy |\n|---------|----------|\n| Standard Blade output | Double-curly-brace syntax auto-escapes all output (Laravel default) |\n| Rich HTML output | Unescaped syntax used only for trusted HTML fields: product `description_html`, page `body_html` |\n| HTML input sanitization | Use HTMLPurifier (or equivalent) on save, before persisting to database |\n| Livewire properties | Auto-escaped by default when rendered in Blade |\n| JSON API responses | Laravel escapes JSON output by default |\n\n#### HTML Sanitization Requirements\n\nAn `SanitizeHtml` action class must be created at `app/Actions/SanitizeHtml.php`.\n\n**Allowed HTML elements and attributes:**\n\n| Element | Allowed Attributes |\n|---------|-------------------|\n| `p`, `br`, `strong`, `em`, `u` | none |\n| `ol`, `ul`, `li` | none |\n| `a` | `href` |\n| `img` | `src`, `alt` |\n| `h1` through `h6` | none |\n| `blockquote` | none |\n| `table`, `thead`, `tbody`, `tr`, `th`, `td` | none |\n| `div`, `span` | none |\n\nAll other elements and attributes must be stripped. Empty elements should be removed.\n\n**Where sanitization is applied:** Before persisting product `description_html` and page `body_html` fields. The sanitization runs during the create and update operations for these models.\n\n---\n\n### 4.6 SQL Injection Prevention\n\n**Requirements:**\n\n- All database queries must use Eloquent ORM with parameterized bindings (Laravel default behavior).\n- No raw query strings may contain unsanitized user input.\n- Any use of `whereRaw`, `selectRaw`, or `DB::raw` must use parameter bindings (the `?` placeholder syntax).\n- FTS5 (full-text search) queries require special handling: user input must be sanitized before building the MATCH expression.\n\n#### FTS5 Input Sanitization\n\nBefore using user input in an FTS5 MATCH expression:\n\n1. Strip all characters that are not Unicode letters, Unicode numbers, or whitespace (remove FTS5 special operators like `*`, `AND`, `OR`, `NOT`, `NEAR`, quotes, parentheses, etc.).\n2. Trim leading and trailing whitespace.\n3. Pass the sanitized string via a parameterized binding (never concatenate into the query string).\n\n---\n\n### 4.7 Mass Assignment Protection\n\n**Requirements:**\n\n- Every Eloquent model must define a `$fillable` array listing only the columns that are safe for mass assignment.\n- The pattern `$guarded = []` is never permitted in any model.\n- All controller and Livewire action code must use `$request->validated()` (not `$request->all()` or `$request->input()`) when passing data to model creation or update methods.\n\n---\n\n### 4.8 Password Security\n\n| Setting | Value |\n|---------|-------|\n| Minimum length | 8 characters (for both admin and customer passwords) |\n| Hashing algorithm | Bcrypt (Laravel default) |\n| Password in logs | Never logged - use Laravel's built-in log redaction |\n| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |\n\n**Model requirements:**\n\n- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.\n- Password validation rules must enforce minimum 8 characters and use the `confirmed` rule (requiring a matching `password_confirmation` field).\n\n**Important:** Both the `User` and `Customer` models use `password_hash` as the database column name instead of Laravel's default `password`. Both models must override `getAuthPassword()`:\n\n```php\npublic function getAuthPassword(): string\n{\n    return $this->password_hash;\n}\n```\n\nThis is required for `Auth::attempt()` and `Hash::check()` to work correctly with the custom column name.\n\n---\n\n## 5. Session Configuration\n\nFile: `config/session.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `driver` | `'file'` | Simple, no external dependencies. Compatible with SQLite-based stack without needing Redis. |\n| `lifetime` | 120 (minutes) | Configurable via `SESSION_LIFETIME` env var. |\n| `expire_on_close` | `false` | Session persists until lifetime expires, not on browser close. |\n| `encrypt` | `true` | Session data encrypted at rest on disk. |\n| `cookie` | `'shop_session'` | Custom name to avoid generic fingerprinting. Configurable via `SESSION_COOKIE` env var. |\n| `path` | `'/'` | Cookie available across entire domain. |\n| `domain` | `null` | Laravel determines from request. |\n| `secure` | `true` | HTTPS-only cookies in production. Configurable via `SESSION_SECURE_COOKIE` env var (set to false for local dev). |\n| `http_only` | `true` | Cookie inaccessible to JavaScript (mitigates XSS-based cookie theft). |\n| `same_site` | `'lax'` | Allows navigation-initiated requests while preventing CSRF from external origins. |\n| `partitioned` | `false` | Not needed for this use case. |\n\n---\n\n## 6. CORS Configuration\n\nFile: `config/cors.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `paths` | `['api/*', 'sanctum/csrf-cookie']` | Only API and Sanctum CSRF cookie endpoints are CORS-enabled. Web routes are not. |\n| `allowed_methods` | `['*']` | All HTTP methods allowed for API. |\n| `allowed_origins` | `[FRONTEND_URL env var, default '*']` | Restrict to store domain in production. Wildcard for development. |\n| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |\n| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |\n| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |\n| `max_age` | `0` | No preflight caching (conservative default). |\n| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |\n\n---\n\n## 7. Webhook Signature Verification\n\n### 7.1 Stripe Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\StripeWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw request body]\n    B --> C[Get Stripe-Signature header]\n    C --> D[Get STRIPE_WEBHOOK_SECRET from config]\n    D --> E{Verify signature using Stripe SDK}\n    E -- Invalid --> F[Return 400 Bad Request]\n    E -- Valid --> G[Parse event type]\n    G --> H{Route to handler}\n    H --> I[checkout.session.completed]\n    H --> J[payment_intent.succeeded]\n    H --> K[charge.refunded]\n    H --> L[Unknown event - ignore]\n    I --> M[Return 200 OK]\n    J --> M\n    K --> M\n    L --> M\n```\n\n**Requirements:**\n\n1. Read the raw request body (not parsed JSON) because signature verification depends on the exact byte sequence.\n2. Retrieve the `Stripe-Signature` header from the incoming request.\n3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.\n4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.\n5. On signature verification failure: return 400 immediately.\n6. On success: dispatch to the appropriate handler based on event type, then return 200.\n7. Handled event types: `checkout.session.completed`, `payment_intent.succeeded`, `charge.refunded`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.2 PayPal Webhook Verification\n\nController: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`\n\n```mermaid\nflowchart TD\n    A[Receive POST request] --> B[Read raw body and headers]\n    B --> C[Call PayPal verification API with webhook ID + headers + body]\n    C --> D{verification_status == SUCCESS?}\n    D -- No --> E[Return 400 Bad Request]\n    D -- Yes --> F[Parse event type from body]\n    F --> G{Route to handler}\n    G --> H[PAYMENT.CAPTURE.COMPLETED]\n    G --> I[PAYMENT.CAPTURE.REFUNDED]\n    G --> J[Unknown event - ignore]\n    H --> K[Return 200 OK]\n    I --> K\n    J --> K\n```\n\n**Requirements:**\n\n1. Read the raw request body and all relevant PayPal headers.\n2. Call PayPal's webhook verification API endpoint, passing the webhook ID, transmission headers, and request body.\n3. Check the `verification_status` field in the PayPal response. Only proceed if it equals `SUCCESS`.\n4. On verification failure: return 400.\n5. Handled event types: `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED`. Unknown events are acknowledged (200) but ignored.\n\n---\n\n### 7.3 Platform Webhooks (Outbound)\n\nService: `App\\Services\\WebhookDispatcher`\n\nWhen the platform sends webhooks to external subscribers (third-party apps, integrations).\n\n```mermaid\nsequenceDiagram\n    participant App as Platform\n    participant Sub as Subscriber URL\n\n    App->>App: Serialize payload as JSON\n    App->>App: Generate Unix timestamp\n    App->>App: Concatenate: \"{timestamp}.{json_body}\"\n    App->>App: HMAC-SHA256(signing_secret, concatenated_message)\n    App->>Sub: POST to subscriber URL\n    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json\n    Sub-->>App: 200 OK (acknowledged)\n```\n\n**Requirements:**\n\n1. The payload is serialized as JSON.\n2. A Unix timestamp is generated (current time).\n3. The message to sign is: `\"{timestamp}.{json_body}\"` (timestamp, dot, JSON string).\n4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).\n5. The HTTP request to the subscriber includes three headers:\n   - `X-Platform-Signature`: the hex-encoded HMAC digest\n   - `X-Platform-Timestamp`: the Unix timestamp as a string\n   - `Content-Type`: `application/json`\n6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.\n\n---\n\n## 8. Audit Logging\n\n### 8.1 Logging Channel\n\nA dedicated `audit` channel must be added to `config/logging.php`.\n\n| Setting | Value |\n|---------|-------|\n| Driver | `daily` (file-based, daily rotation) |\n| Path | `storage/logs/audit.log` |\n| Level | `info` |\n| Retention | 90 days |\n\n---\n\n### 8.2 Log Entry Format\n\nAll audit log entries are structured JSON objects with the following schema:\n\n| Field | Type | Required | Description |\n|-------|------|:--------:|-------------|\n| `timestamp` | string (ISO-8601) | Y | When the event occurred |\n| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |\n| `user_id` | integer or null | N | ID of the admin user who triggered the event |\n| `store_id` | integer or null | N | ID of the store context |\n| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |\n| `resource_id` | integer or null | N | ID of the affected resource |\n| `ip` | string | Y | Client IP address |\n| `user_agent` | string | Y | Client user agent string |\n| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |\n| (additional keys) | varies | N | Event-specific extra data |\n\n---\n\n### 8.3 Events to Log\n\n#### Authentication Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `auth.login` | Successful admin login | user_id, ip, user_agent |\n| `auth.logout` | Admin logout | user_id |\n| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |\n| `customer.login` | Successful customer login | customer_id, store_id, ip |\n| `customer.registered` | New customer registration | customer_id, store_id |\n| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |\n\n#### Admin Resource Events\n\nFor each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:\n\n**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff\n\n| Event Pattern | Trigger | Extra Data |\n|---------------|---------|------------|\n| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |\n| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |\n| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |\n| `{resource}.restored` | Resource restored from soft-delete | resource_id |\n\n#### Security Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `api_token.created` | New API token issued | token_name, abilities list, user_id |\n| `api_token.revoked` | API token revoked | token_name, user_id |\n| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |\n| `store.settings_changed` | Store settings updated | changed fields with old/new values |\n\n---\n\n### 8.4 Audit Logger Service\n\nFile: `app/Services/AuditLogger.php`\n\nA service class that encapsulates audit log writing.\n\n**Method signature:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|:--------:|-------------|\n| `event` | string | Y | Event identifier |\n| `userId` | int or null | N | Acting user ID |\n| `storeId` | int or null | N | Store context ID |\n| `resourceType` | string or null | N | Affected resource type |\n| `resourceId` | int or null | N | Affected resource ID |\n| `extra` | associative array | N | Additional key-value pairs to include in the log entry |\n\n**Behavior:**\n\n1. Write to the `audit` logging channel at the `info` level.\n2. Automatically include `timestamp` (ISO-8601), `ip` (from request), and `user_agent` (from request) in every entry.\n3. Merge any `extra` data into the log context.\n\n---\n\n### 8.5 Integration with Laravel Events\n\nThe audit logger hooks into Laravel's built-in authentication events and custom model observers.\n\n#### Auth Event Listeners\n\nRegister listeners (in `AppServiceProvider` or `EventServiceProvider`) for these Laravel events:\n\n| Laravel Event | Audit Event ID | Data Captured |\n|--------------|----------------|---------------|\n| `Illuminate\\Auth\\Events\\Login` | `auth.login` | user_id from event |\n| `Illuminate\\Auth\\Events\\Failed` | `auth.failed_login` | email from credentials (never password) |\n| `Illuminate\\Auth\\Events\\Logout` | `auth.logout` | user_id from event |\n\n#### Model Observers\n\nFor each auditable resource model, create an observer (or use a shared trait) that logs `created`, `updated`, and `deleted` events. The `updated` observer should capture the changed fields (dirty attributes) with their original and new values.\n\n---\n\n## 9. Security Checklist Summary\n\n| # | Control | Implementation Approach | Priority |\n|---|---------|------------------------|----------|\n| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |\n| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |\n| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |\n| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |\n| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |\n| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |\n","structuredPatch":[{"oldStart":932,"oldLines":73,"newStart":932,"newLines":14,"lines":[" "," ## 7. Webhook Signature Verification"," ","-### 7.1 Stripe Webhook Verification","+### 7.1 Inbound Payment Webhooks"," ","-Controller: `App\\Http\\Controllers\\Webhooks\\StripeWebhookController`","+> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification)."," ","-```mermaid","-flowchart TD","-    A[Receive POST request] --> B[Read raw request body]","-    B --> C[Get Stripe-Signature header]","-    C --> D[Get STRIPE_WEBHOOK_SECRET from config]","-    D --> E{Verify signature using Stripe SDK}","-    E -- Invalid --> F[Return 400 Bad Request]","-    E -- Valid --> G[Parse event type]","-    G --> H{Route to handler}","-    H --> I[checkout.session.completed]","-    H --> J[payment_intent.succeeded]","-    H --> K[charge.refunded]","-    H --> L[Unknown event - ignore]","-    I --> M[Return 200 OK]","-    J --> M","-    K --> M","-    L --> M","-```","-","-**Requirements:**","-","-1. Read the raw request body (not parsed JSON) because signature verification depends on the exact byte sequence.","-2. Retrieve the `Stripe-Signature` header from the incoming request.","-3. Use the Stripe SDK's built-in webhook verification, which performs a timing-safe HMAC comparison.","-4. The webhook secret comes from config (`services.stripe.webhook_secret`), which reads from the `STRIPE_WEBHOOK_SECRET` env var.","-5. On signature verification failure: return 400 immediately.","-6. On success: dispatch to the appropriate handler based on event type, then return 200.","-7. Handled event types: `checkout.session.completed`, `payment_intent.succeeded`, `charge.refunded`. Unknown events are acknowledged (200) but ignored.","-"," ---"," ","-### 7.2 PayPal Webhook Verification","+### 7.2 Platform Webhooks (Outbound)"," ","-Controller: `App\\Http\\Controllers\\Webhooks\\PayPalWebhookController`","-","-```mermaid","-flowchart TD","-    A[Receive POST request] --> B[Read raw body and headers]","-    B --> C[Call PayPal verification API with webhook ID + headers + body]","-    C --> D{verification_status == SUCCESS?}","-    D -- No --> E[Return 400 Bad Request]","-    D -- Yes --> F[Parse event type from body]","-    F --> G{Route to handler}","-    G --> H[PAYMENT.CAPTURE.COMPLETED]","-    G --> I[PAYMENT.CAPTURE.REFUNDED]","-    G --> J[Unknown event - ignore]","-    H --> K[Return 200 OK]","-    I --> K","-    J --> K","-```","-","-**Requirements:**","-","-1. Read the raw request body and all relevant PayPal headers.","-2. Call PayPal's webhook verification API endpoint, passing the webhook ID, transmission headers, and request body.","-3. Check the `verification_status` field in the PayPal response. Only proceed if it equals `SUCCESS`.","-4. On verification failure: return 400.","-5. Handled event types: `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED`. Unknown events are acknowledged (200) but ignored.","-","----","-","-### 7.3 Platform Webhooks (Outbound)","-"," Service: `App\\Services\\WebhookDispatcher`"," "," When the platform sends webhooks to external subscribers (third-party apps, integrations)."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"faf00f95-2b0e-4454-8f75-f131ca6258de"}
{"parentUuid":"bbd797c5-3769-47ca-9a23-effd6b4e8757","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QquCdG8kVd7dg7u4YdZb6X","type":"message","role":"assistant","content":[{"type":"text","text":"Now fix the remaining Stripe references in the security audit log and requirements table."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1054,"cache_read_input_tokens":160070,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1054},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X77UfzotTcaQj4TFF7","type":"assistant","uuid":"badfc14c-eb02-4483-8632-3e5dca831dad","timestamp":"2026-02-11T15:29:38.362Z"}
{"parentUuid":"badfc14c-eb02-4483-8632-3e5dca831dad","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QquCdG8kVd7dg7u4YdZb6X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HWHPn35v88Qe5VMCjhWJiL","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","offset":1090,"limit":20}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1054,"cache_read_input_tokens":160070,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1054},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X77UfzotTcaQj4TFF7","type":"assistant","uuid":"fba1c0f0-ae0c-4536-87a2-350c84737c48","timestamp":"2026-02-11T15:29:39.269Z"}
{"parentUuid":"fba1c0f0-ae0c-4536-87a2-350c84737c48","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01HWHPn35v88Qe5VMCjhWJiL","toolUseID":"toolu_01HWHPn35v88Qe5VMCjhWJiL","timestamp":"2026-02-11T15:29:39.272Z","uuid":"2749f8ee-836f-472b-8206-59f0b6f863be"}
{"parentUuid":"fba1c0f0-ae0c-4536-87a2-350c84737c48","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HWHPn35v88Qe5VMCjhWJiL","type":"tool_result","content":"  1090→\n  1091→## 9. Security Checklist Summary\n  1092→\n  1093→| # | Control | Implementation Approach | Priority |\n  1094→|---|---------|------------------------|----------|\n  1095→| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n  1096→| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n  1097→| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n  1098→| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n  1099→| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n  1100→| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n  1101→| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n  1102→| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n  1103→| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n  1104→| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n  1105→| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |\n  1106→| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |\n  1107→| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |\n  1108→| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |\n  1109→| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |"}]},"uuid":"ad567453-89a6-40aa-9506-b97062090412","timestamp":"2026-02-11T15:29:39.272Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"\n## 9. Security Checklist Summary\n\n| # | Control | Implementation Approach | Priority |\n|---|---------|------------------------|----------|\n| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |\n| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |\n| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |\n| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |\n| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |","numLines":20,"startLine":1090,"totalLines":1111}},"sourceToolAssistantUUID":"fba1c0f0-ae0c-4536-87a2-350c84737c48"}
{"parentUuid":"ad567453-89a6-40aa-9506-b97062090412","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01QquCdG8kVd7dg7u4YdZb6X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AeE9ExcJKc8kzkjZuWN5pq","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","offset":1160,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1054,"cache_read_input_tokens":160070,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1054},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X77UfzotTcaQj4TFF7","type":"assistant","uuid":"04eb2b0f-1cd3-4415-8a42-a1dea0beacfb","timestamp":"2026-02-11T15:29:39.866Z"}
{"parentUuid":"04eb2b0f-1cd3-4415-8a42-a1dea0beacfb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01AeE9ExcJKc8kzkjZuWN5pq","toolUseID":"toolu_01AeE9ExcJKc8kzkjZuWN5pq","timestamp":"2026-02-11T15:29:39.868Z","uuid":"d72300d4-fcf0-4e62-b615-d1dd83e35842"}
{"parentUuid":"04eb2b0f-1cd3-4415-8a42-a1dea0beacfb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AeE9ExcJKc8kzkjZuWN5pq","type":"tool_result","content":"<system-reminder>Warning: the file exists but is shorter than the provided offset (1160). The file has 1111 lines.</system-reminder>"}]},"uuid":"bffa5538-f052-4584-80e9-03df5d5e1015","timestamp":"2026-02-11T15:29:39.868Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"","numLines":0,"startLine":1160,"totalLines":1111}},"sourceToolAssistantUUID":"04eb2b0f-1cd3-4415-8a42-a1dea0beacfb"}
{"parentUuid":"bffa5538-f052-4584-80e9-03df5d5e1015","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RPTrnNid2hVyWf1ZL1geME","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01R5CyskkVDrkDthTPDVPNR8","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","old_string":"| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |","new_string":"| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":888,"cache_read_input_tokens":161124,"output_tokens":210,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":888,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2X7V1yh1CpWkxgD1393","type":"assistant","uuid":"0569146a-8f91-4d10-b7e3-088585fafe3c","timestamp":"2026-02-11T15:29:44.668Z"}
{"parentUuid":"0569146a-8f91-4d10-b7e3-088585fafe3c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01R5CyskkVDrkDthTPDVPNR8","toolUseID":"toolu_01R5CyskkVDrkDthTPDVPNR8","timestamp":"2026-02-11T15:29:44.673Z","uuid":"a64b1e92-0f66-4860-b310-b87427d8ea85"}
{"parentUuid":"0569146a-8f91-4d10-b7e3-088585fafe3c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01R5CyskkVDrkDthTPDVPNR8","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md has been updated successfully."}]},"uuid":"90a709eb-617a-4c96-a460-884868792f86","timestamp":"2026-02-11T15:29:44.672Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","oldString":"| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |","newString":"| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |","originalFile":"# 06 - Authentication and Security Specification\n\n> Shopify-clone e-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite\n\n---\n\n## 1. Authentication\n\nThe platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.\n\n### 1.1 Admin Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `web` (default Laravel guard) |\n| Provider name | `users` (Eloquent driver) |\n| Model | `App\\Models\\User` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /admin/login` |\n| Login form | Livewire component `Admin\\Auth\\Login` |\n\n#### Admin Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (web)\n    participant DB as Database\n    participant S as Session\n\n    B->>LW: Submit email + password\n    LW->>LW: Validate input (email required, password required)\n    LW->>LW: Check rate limiter (5/min per IP)\n    alt Rate limit exceeded\n        LW-->>B: Flash \"Too many attempts. Try again in X seconds.\"\n    end\n    LW->>Auth: attempt(credentials, remember)\n    Auth->>DB: SELECT from users WHERE email = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Credentials valid\n        Auth->>S: Regenerate session ID (prevent fixation)\n        Auth-->>LW: Success\n        LW-->>B: Redirect to /admin (dashboard)\n    else Credentials invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\" (generic, never reveal which field)\n    end\n```\n\n**Requirements:**\n\n- The login form collects `email` and `password`.\n- On successful login, the session MUST be regenerated to prevent session fixation attacks.\n- On failed login, only the generic message \"Invalid credentials\" is shown. The system must not indicate whether the email or password was wrong.\n- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.\n- After login: redirect to `/admin` (the dashboard).\n\n#### Remember Me\n\n- An optional \"Remember me\" checkbox on the login form.\n- When enabled, Laravel sets a long-lived remember token cookie.\n- The `remember_token` column on the `users` table stores the hashed token.\n- Cookie name follows Laravel default convention: `remember_web_{hash}`.\n\n#### Admin Password Reset Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant App as Application\n    participant DB as Database\n    participant Mail as Mail Service\n\n    B->>App: GET /admin/forgot-password\n    App-->>B: Render forgot-password form\n    B->>App: POST /admin/forgot-password (email)\n    App->>DB: Look up user by email\n    alt User exists\n        App->>DB: Store reset token in password_reset_tokens table\n        App->>Mail: Send reset link email\n    end\n    App-->>B: \"If that email exists, we sent a reset link.\" (generic)\n    B->>App: GET /admin/reset-password/{token}\n    App-->>B: Render reset-password form\n    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)\n    App->>DB: Validate token (exists, not expired, matches email)\n    alt Valid token\n        App->>DB: Update user password (bcrypt hash)\n        App->>DB: Delete used token\n        App-->>B: Redirect to /admin/login with success flash\n    else Invalid/expired token\n        App-->>B: Error message\n    end\n```\n\n**Requirements:**\n\n- Uses Laravel's built-in password broker (`users` broker).\n- Reset tokens stored in the `password_reset_tokens` table.\n- Token expiration: 60 minutes (configurable in `config/auth.php` under `passwords.users.expire`).\n- Throttle: one reset email per 60 seconds per email address.\n- The \"forgot password\" response must be generic (do not reveal whether email exists).\n\n#### Admin Logout\n\n- Route: `POST /admin/logout` (via Livewire action or standard route).\n- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.\n- The response must not cache.\n\n#### Session Lifetime\n\n| Setting | Value |\n|---------|-------|\n| Lifetime | 120 minutes (configurable via `SESSION_LIFETIME` env var) |\n| Expire on close | No (session persists until lifetime expires) |\n\n---\n\n### 1.2 Customer Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `customer` (custom guard) |\n| Provider name | `customers` (Eloquent driver) |\n| Model | `App\\Models\\Customer` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /account/login` |\n| Registration route | `GET /account/register` |\n\n#### Customer Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (customer)\n    participant Store as ResolveStore Middleware\n    participant DB as Database\n\n    Note over Store: Store already resolved from hostname\n    B->>LW: Submit email + password\n    LW->>LW: Validate input\n    LW->>LW: Check rate limiter (5/min per IP)\n    LW->>Auth: attempt(credentials + store_id, remember)\n    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Valid\n        Auth-->>LW: Success (regenerate session)\n        LW-->>B: Redirect to /account (or intended URL)\n    else Invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\"\n    end\n```\n\n**Requirements:**\n\n- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.\n- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.\n- The same email address can exist in different stores (multi-tenant isolation).\n- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.\n- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).\n\n#### Customer Registration Flow\n\n```mermaid\nflowchart TD\n    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]\n    B --> C{Validate via RegisterCustomerRequest}\n    C -- Invalid --> D[Show validation errors]\n    C -- Valid --> E[Create Customer record scoped to current store]\n    E --> F[Auto-login via customer guard]\n    F --> G[Redirect to /account]\n```\n\n**Registration fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|:--------:|------------|\n| name | string | Y | max:255 |\n| email | string | Y | valid email, unique per store (composite unique on email + store_id) |\n| password | string | Y | min:8, confirmed |\n| password_confirmation | string | Y | must match password |\n| marketing_opt_in | boolean | N | defaults to false |\n\n#### Customer Store Scoping\n\n- The `customers` auth provider requires a custom `CustomerUserProvider` class.\n- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.\n- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).\n- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.\n\n#### Customer Password Reset\n\n- Uses a separate password broker: `customers` broker.\n- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).\n- Token records include `store_id` to scope resets to the correct store.\n- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.\n- Token expiration: 60 minutes.\n- Throttle: one reset email per 60 seconds.\n- Generic response on the \"forgot password\" form (do not reveal whether email exists in the store).\n\n---\n\n### 1.3 API Authentication (Sanctum)\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `sanctum` |\n| Provider | `users` (admin users only) |\n| Token type | Personal access tokens |\n| Token prefix | `shop_` |\n| Hashing | SHA-256 |\n| Default expiration | 1 year |\n| Storage | `personal_access_tokens` table |\n\n#### Token Lifecycle\n\n```mermaid\nstateDiagram-v2\n    [*] --> Created: Admin creates token in Developers section\n    Created --> Active: Plain-text shown once, hash stored\n    Active --> InUse: Token sent as Bearer header\n    InUse --> Active: Request processed\n    Active --> Revoked: Admin revokes token\n    Active --> Expired: Expiration date reached\n    Revoked --> [*]: Token row deleted\n    Expired --> [*]: Token row deleted (cleanup job)\n```\n\n**Requirements:**\n\n- Tokens are created in the admin panel, under the Developers section.\n- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).\n- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.\n- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.\n- Sanctum middleware validates the hash and resolves the associated user.\n- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).\n- Revoking a token deletes its row from the `personal_access_tokens` table.\n\n#### Token Abilities (Scopes)\n\n| Ability | Description |\n|---------|-------------|\n| `read-products` | List and view products |\n| `write-products` | Create, update, delete products |\n| `read-orders` | List and view orders |\n| `write-orders` | Update orders, create fulfillments |\n| `read-customers` | List and view customers |\n| `write-customers` | Update customers |\n| `read-collections` | List and view collections |\n| `write-collections` | Create, update, delete collections |\n| `read-discounts` | List and view discounts |\n| `write-discounts` | Create, update, delete discounts |\n| `read-analytics` | View analytics data |\n| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |\n| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |\n| `read-themes` | View themes and theme files |\n| `write-themes` | Create, update, delete, publish themes |\n| `read-content` | View pages and navigation menus |\n| `write-content` | Create, update, delete pages and navigation items |\n| `manage-platform` | Platform-level management (super-admin only) |\n\nEach API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.\n\n---\n\n### 1.4 Guard Configuration\n\nFile to configure: `config/auth.php`\n\n#### Guards Data Dictionary\n\n| Guard Name | Driver | Provider | Purpose |\n|------------|--------|----------|---------|\n| `web` | session | `users` | Admin panel session auth (default guard) |\n| `customer` | session | `customers` | Storefront customer session auth |\n| `sanctum` | sanctum | `users` | Admin REST API token auth |\n\n#### Providers Data Dictionary\n\n| Provider Name | Driver | Model | Purpose |\n|---------------|--------|-------|---------|\n| `users` | eloquent | `App\\Models\\User` | Admin users |\n| `customers` | eloquent | `App\\Models\\Customer` | Storefront customers (store-scoped via custom provider) |\n\n#### Password Brokers Data Dictionary\n\n| Broker Name | Provider | Token Table | Expiry | Throttle |\n|-------------|----------|-------------|--------|----------|\n| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |\n| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |\n\n**Defaults:** The default guard is `web` and the default password broker is `users`.\n\n---\n\n## 2. Authorization\n\n### 2.1 Role Enum\n\nFile: `app/Enums/StoreUserRole.php`\n\nA backed string enum with four values:\n\n| Case | Stored Value | Description |\n|------|-------------|-------------|\n| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |\n| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |\n| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |\n| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |\n\n**Constraints:**\n\n- Each store must have exactly one Owner.\n- Ownership transfer is a dedicated action (not a simple role change).\n- A User can hold different roles in different stores (via the `store_users` pivot table).\n\n---\n\n### 2.2 Permission Matrix\n\nThe `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.\n\n| Resource | Action | Owner | Admin | Staff | Support |\n|----------|--------|:-----:|:-----:|:-----:|:-------:|\n| Store Settings | View | Y | Y | N | N |\n| Store Settings | Update | Y | Y | N | N |\n| Store | Delete | Y | N | N | N |\n| Products | List / View | Y | Y | Y | Y |\n| Products | Create | Y | Y | Y | N |\n| Products | Update | Y | Y | Y | N |\n| Products | Delete / Archive | Y | Y | N | N |\n| Products | Restore | Y | Y | N | N |\n| Collections | List / View | Y | Y | Y | Y |\n| Collections | Create | Y | Y | Y | N |\n| Collections | Update | Y | Y | Y | N |\n| Collections | Delete | Y | Y | N | N |\n| Orders | List / View | Y | Y | Y | Y |\n| Orders | Update | Y | Y | Y | N |\n| Orders | Cancel | Y | Y | N | N |\n| Fulfillments | Create | Y | Y | Y | N |\n| Fulfillments | Update / Cancel | Y | Y | Y | N |\n| Refunds | Create | Y | Y | N | N |\n| Customers | List / View | Y | Y | Y | Y |\n| Customers | Update | Y | Y | Y | N |\n| Discounts | List / View | Y | Y | Y | Y |\n| Discounts | Create | Y | Y | Y | N |\n| Discounts | Update | Y | Y | Y | N |\n| Discounts | Delete | Y | Y | N | N |\n| Themes | List / View | Y | Y | N | N |\n| Themes | Create / Upload | Y | Y | N | N |\n| Themes | Customize | Y | Y | N | N |\n| Themes | Publish | Y | Y | N | N |\n| Themes | Delete | Y | Y | N | N |\n| Pages | List / View | Y | Y | Y | N |\n| Pages | Create | Y | Y | Y | N |\n| Pages | Update | Y | Y | Y | N |\n| Pages | Delete | Y | Y | N | N |\n| Navigation | List / View | Y | Y | Y | N |\n| Navigation | Manage | Y | Y | N | N |\n| Apps | List / View | Y | Y | N | N |\n| Apps | Install / Remove | Y | Y | N | N |\n| Analytics | View | Y | Y | Y | N |\n| Developers / API | Manage | Y | Y | N | N |\n| Staff | List / View | Y | Y | N | N |\n| Staff | Invite | Y | Y | N | N |\n| Staff | Update Role | Y | Y | N | N |\n| Staff | Remove | Y | Y | N | N |\n| Shipping Zones | Manage | Y | Y | N | N |\n| Tax Settings | Manage | Y | Y | N | N |\n| Search Settings | Manage | Y | Y | N | N |\n\n#### Role Group Shorthand\n\nThe permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:\n\n| Group Name | Roles Included | Used For |\n|------------|---------------|----------|\n| OwnerOnly | Owner | Store deletion, ownership transfer |\n| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |\n| OwnerAdminOrStaff | Owner, Admin, Staff | Create/update products, collections, discounts, pages, fulfillments, order updates |\n| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |\n\n---\n\n### 2.3 Role Checking Trait\n\nFile: `app/Traits/ChecksStoreRole.php`\n\nA reusable trait included in all policies and used by gate definitions. Must provide the following methods:\n\n| Method | Parameters | Returns | Description |\n|--------|-----------|---------|-------------|\n| `getStoreRole` | User, store_id (int) | StoreUserRole or null | Looks up the user's role for the given store via the `store_users` relationship. Returns null if the user has no role in that store. |\n| `hasRole` | User, store_id (int), roles (array of StoreUserRole) | bool | Returns true if the user's role for the store is in the provided list. Returns false if user has no role. |\n| `isOwnerOrAdmin` | User, store_id (int) | bool | Shorthand: checks if role is Owner or Admin. |\n| `isOwnerAdminOrStaff` | User, store_id (int) | bool | Shorthand: checks if role is Owner, Admin, or Staff. |\n| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |\n\n**Store ID resolution:** When the policy method receives a model instance (e.g., Product), the store_id comes from the model's `store_id` attribute. When the policy method does not receive a model (e.g., `viewAny`, `create`), the store_id is resolved from the container-bound `current_store`.\n\n---\n\n### 2.4 Laravel Policies\n\nAll policies use the `ChecksStoreRole` trait. Laravel 12 auto-discovers policies by convention (`App\\Policies\\{Model}Policy`), so no manual registration is needed.\n\nEvery policy method follows this pattern:\n1. Determine the `store_id` (from the model if available, otherwise from the resolved current store).\n2. Use the appropriate role-checking helper method.\n3. Return a boolean.\n\n#### ProductPolicy\n\nFile: `app/Policies/ProductPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Product)` | Yes | AnyRole | product.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Product)` | Yes | OwnerAdminOrStaff | product.store_id |\n| `delete(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `archive(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `restore(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n\n#### OrderPolicy\n\nFile: `app/Policies/OrderPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Order)` | Yes | AnyRole | order.store_id |\n| `update(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `cancel(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n| `createFulfillment(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `createRefund(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n\n#### CollectionPolicy\n\nFile: `app/Policies/CollectionPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Collection)` | Yes | AnyRole | collection.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Collection)` | Yes | OwnerAdminOrStaff | collection.store_id |\n| `delete(User, Collection)` | Yes | OwnerOrAdmin | collection.store_id |\n\n#### DiscountPolicy\n\nFile: `app/Policies/DiscountPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Discount)` | Yes | AnyRole | discount.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Discount)` | Yes | OwnerAdminOrStaff | discount.store_id |\n| `delete(User, Discount)` | Yes | OwnerOrAdmin | discount.store_id |\n\n#### CustomerPolicy\n\nFile: `app/Policies/CustomerPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Customer)` | Yes | AnyRole | customer.store_id |\n| `update(User, Customer)` | Yes | OwnerAdminOrStaff | customer.store_id |\n\n#### ThemePolicy\n\nFile: `app/Policies/ThemePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerOrAdmin | current_store |\n| `view(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `create(User)` | No | OwnerOrAdmin | current_store |\n| `update(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `delete(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `publish(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n\n#### PagePolicy\n\nFile: `app/Policies/PagePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `view(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `delete(User, Page)` | Yes | OwnerOrAdmin | page.store_id |\n\n#### StorePolicy\n\nFile: `app/Policies/StorePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `updateSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `delete(User, Store)` | Yes | OwnerOnly | store.id |\n\n#### FulfillmentPolicy\n\nFile: `app/Policies/FulfillmentPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerAdminOrStaff | order.store_id |\n| `update(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n| `cancel(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n\nNote: `create` receives the parent Order, not a Fulfillment, since the Fulfillment does not exist yet. The `update` and `cancel` methods navigate through the Fulfillment's order relationship to get the store_id.\n\n#### RefundPolicy\n\nFile: `app/Policies/RefundPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerOrAdmin | order.store_id |\n\nNote: `create` receives the parent Order for the same reason as FulfillmentPolicy.\n\n#### NavigationMenuPolicy\n\nFile: `app/Policies/NavigationMenuPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `manage(User)` | No | OwnerOrAdmin | current_store |\n\n---\n\n### 2.5 Gates (Non-Model Operations)\n\nGates are registered in `AppServiceProvider` (or a dedicated `AuthServiceProvider`). Each gate resolves the current store from the container and checks the user's role via the `store_users` relationship.\n\n| Gate Name | Required Roles | Purpose |\n|-----------|---------------|---------|\n| `manage-store-settings` | Owner, Admin | Access store settings pages |\n| `manage-staff` | Owner, Admin | Invite, update, remove staff members |\n| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |\n| `view-analytics` | Owner, Admin, Staff | View analytics dashboard and reports |\n| `manage-shipping` | Owner, Admin | Manage shipping zones, rates, profiles |\n| `manage-taxes` | Owner, Admin | Manage tax settings and rates |\n| `manage-search-settings` | Owner, Admin | Configure search weights and synonyms |\n| `manage-navigation` | Owner, Admin | Create/edit/delete navigation menus |\n| `manage-apps` | Owner, Admin | Install and remove third-party apps |\n\n**Gate resolution pattern (all gates follow this):**\n\n1. Resolve the current store from the container\n2. Look up the user's `store_users` record for that store\n3. If no record exists, return false\n4. If the user's role is in the gate's required roles list, return true\n5. Otherwise return false\n\n---\n\n### 2.6 Policy and Gate Usage Patterns\n\n#### In Livewire Components\n\n- List views: call `authorize('viewAny', ModelClass)` in the `mount()` lifecycle method.\n- Action methods (delete, update, etc.): call `authorize('action', $modelInstance)` at the start of the action, before performing any mutation.\n- If authorization fails, Laravel throws `AuthorizationException`, which renders as 403.\n\n#### In API Controllers\n\n- Use `$this->authorize('action', $model)` at the start of each controller method.\n- Alternatively, use `can` middleware on routes for simpler checks.\n- Form Requests also implement `authorize()` to check the relevant policy or gate before validation runs.\n\n#### In Blade Templates\n\n- Use `@can('gate-name')` / `@cannot` directives to conditionally show/hide navigation items and action buttons.\n- This is purely cosmetic (UI hiding) - the actual authorization check happens server-side in policies/gates.\n\n---\n\n## 3. Middleware Stack\n\n### 3.1 Middleware Registration\n\nFile: `bootstrap/app.php`\n\nThree custom middleware aliases must be registered:\n\n| Alias | Class | Purpose |\n|-------|-------|---------|\n| `store.resolve` | `App\\Http\\Middleware\\ResolveStore` | Resolves current store from hostname or session |\n| `role.check` | `App\\Http\\Middleware\\CheckStoreRole` | Checks user has required role for current store |\n| `auth:customer` | `App\\Http\\Middleware\\CustomerAuthenticate` | Ensures customer is authenticated via customer guard |\n\n---\n\n### 3.2 Route Middleware Groups\n\n| Route Group | Middleware Stack | Description |\n|-------------|-----------------|-------------|\n| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |\n\n---\n\n### 3.3 Custom Middleware Specifications\n\n#### ResolveStore\n\nFile: `app/Http/Middleware/ResolveStore.php`\n\n**Purpose:** Resolves the current store from the request hostname and binds it to the service container.\n\n```mermaid\nflowchart TD\n    A[Extract hostname from request] --> B{Route type?}\n    B -- Storefront --> C{Match hostname in store_domains table?}\n    C -- Yes --> F[Store found]\n    C -- No --> E[Abort 404: Store not found]\n    F --> G{Store status == suspended?}\n    G -- Yes --> H[Abort 503: Store unavailable]\n    G -- No --> I[Bind Store to container as 'current_store']\n    I --> J[Share Store with all views as 'currentStore']\n    J --> K[Continue to next middleware]\n    B -- Admin --> L[Read store_id from session 'current_store_id']\n    L --> M{User has store_users record?}\n    M -- No --> N[Abort 403]\n    M -- Yes --> I\n```\n\n**Requirements:**\n\nStorefront requests: Resolve store from the `store_domains` table by matching the request hostname against `store_domains.hostname`. Cache the hostname-to-store mapping using file cache (tag: `store-domains`).\n\nAdmin requests: Store is determined by session key `current_store_id`, set after login or via the store-switcher in the admin panel. Admin routes do NOT use hostname resolution. The `admin.acme-fashion.test` domain in seeders routes requests to the admin panel, but the actual store context within the admin is determined by the session `current_store_id` after login.\n\n**Shared steps (after store is resolved):**\n\n1. If the store's `status` column is `'suspended'`: abort with HTTP 503 and message \"This store is currently unavailable.\" (For admin routes: abort 403 for mutations instead.)\n2. Bind the resolved Store model instance to the service container with the key `'current_store'`.\n3. Share the store with all Blade views under the variable name `currentStore`.\n4. Allow the request to proceed.\n\n#### CheckStoreRole\n\nFile: `app/Http/Middleware/CheckStoreRole.php`\n\n**Purpose:** Verifies the authenticated user has one of the specified roles for the current store.\n\n**Route usage:** Applied as `role.check:owner,admin` or `role.check:owner,admin,staff` (comma-separated role values passed as variadic parameters).\n\n```mermaid\nflowchart TD\n    A[Resolve current_store from container] --> B[Get authenticated user]\n    B --> C[Look up store_users record for user + store]\n    C --> D{Record exists?}\n    D -- No --> E[Abort 403: No access to this store]\n    D -- Yes --> F[Parse allowed roles from middleware parameters]\n    F --> G{User's role in allowed list?}\n    G -- No --> H[Abort 403: Insufficient permissions]\n    G -- Yes --> I[Attach store_user to request attributes]\n    I --> J[Continue to next middleware]\n```\n\n**Requirements:**\n\n1. Resolve the current store from the container.\n2. Get the authenticated user from the request.\n3. Query the user's `store_users` relationship filtered by `store_id`.\n4. If no record exists: abort 403 with message \"You do not have access to this store.\"\n5. Convert the middleware parameter strings to `StoreUserRole` enum instances.\n6. If the user's role is not in the allowed list: abort 403 with message \"Insufficient permissions.\"\n7. Store the resolved `StoreUser` pivot record on the request attributes (key: `store_user`) for downstream use.\n8. Allow the request to proceed.\n\n#### CustomerAuthenticate\n\nFile: `app/Http/Middleware/CustomerAuthenticate.php`\n\n**Purpose:** Ensures the request is authenticated via the `customer` guard. Redirects unauthenticated customers to login.\n\n```mermaid\nflowchart TD\n    A[Check customer guard authentication] --> B{Authenticated?}\n    B -- Yes --> C[Continue to next middleware]\n    B -- No --> D[Store current URL as intended redirect]\n    D --> E[Redirect to /account/login]\n```\n\n**Requirements:**\n\n1. Check if `Auth::guard('customer')` reports the user as authenticated.\n2. If authenticated: allow the request to proceed.\n3. If not authenticated: save the current URL in the session as the intended URL (so the customer returns there after login), then redirect to the storefront login route (`/account/login`).\n\n---\n\n## 4. Security Controls\n\n### 4.1 CSRF Protection\n\n| Context | Mechanism |\n|---------|-----------|\n| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |\n| Livewire actions | Livewire handles CSRF verification automatically on every request |\n| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |\n| SPA clients | `GET /sanctum/csrf-cookie` sets XSRF-TOKEN cookie for session-based API calls |\n\n**Requirements:**\n\n- CSRF verification is enforced globally on all `web` middleware group routes.\n- No routes within the `web` group may be excluded from CSRF verification.\n- API routes (under `api` middleware group) are inherently exempt since they use token auth.\n\n---\n\n### 4.2 Rate Limiting\n\nAll rate limiters are defined in `bootstrap/app.php` (or `AppServiceProvider::boot()`).\n\n| Limiter Name | Limit | Keyed By | Applied To |\n|-------------|-------|----------|------------|\n| `login` | 5 per minute | IP address | Admin login, customer login |\n| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |\n| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |\n| `checkout` | 10 per minute | Session ID | Checkout-related operations |\n| `search` | 30 per minute | IP address | Search queries |\n| `analytics` | 60 per minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |\n\n**Rate limit response headers** (returned on all API responses by Laravel default):\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429 responses) |\n\nWhen a rate limit is exceeded, the response is HTTP 429 Too Many Requests.\n\n---\n\n### 4.3 Encryption at Rest\n\nEncryption algorithm: AES-256-CBC with MAC verification, keyed by the application's `APP_KEY`.\n\n**Fields requiring encrypted storage (using Laravel's `encrypted` cast):**\n\n| Model | Column | Cast Type | Purpose |\n|-------|--------|-----------|---------|\n| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |\n| WebhookSubscription | `signing_secret_encrypted` | `encrypted` | Per-subscription signing secrets for outbound webhooks |\n| Payment | `raw_json` | `encrypted:array` | Full payment gateway response (may contain sensitive card/account data) |\n\n**Requirements:**\n\n- These columns are declared with the `encrypted` (or `encrypted:array`) cast in the model's `casts()` method.\n- Data is automatically encrypted before writing to the database and decrypted when reading.\n- The encrypted columns store opaque ciphertext (type TEXT in SQLite). They cannot be queried with WHERE clauses at the database level.\n\n---\n\n### 4.4 Input Validation\n\nAll write endpoints must use dedicated Form Request classes. Validation rules use string syntax per project convention.\n\n#### Form Request Inventory\n\n| Form Request Class | Used By | Key Validation Rules |\n|-------------------|---------|---------------------|\n| `StoreProductRequest` | Create product | `title`: required, string, max:255. `description_html`: nullable, string. `status`: required, in:active,draft,archived. `vendor`: nullable, string. Price fields: required, integer, min:0. |\n| `UpdateProductRequest` | Update product | Same fields as StoreProductRequest but all optional (sometimes rule). |\n| `StoreCollectionRequest` | Create collection | `title`: required, string, max:255. `type`: required, in:manual,automated. `rules_json`: required_if:type,automated, valid JSON. |\n| `UpdateCollectionRequest` | Update collection | Same as StoreCollectionRequest, all optional. |\n| `StoreDiscountRequest` | Create discount | `type`: required, in:code,automatic. `code`: required_if:type,code, string, max:50, unique per store (case-insensitive). `value_type`: required, in:fixed,percent,free_shipping. `value_amount`: required_unless:value_type,free_shipping, integer, min:0; when value_type is `percent`, max:100. `minimum_order_amount`: nullable, integer, min:0. `usage_limit`: nullable, integer, min:1. `starts_at`: nullable, date, ISO-8601. `ends_at`: nullable, date, after:starts_at. |\n| `UpdateDiscountRequest` | Update discount | Same as StoreDiscountRequest, code unique ignoring self. |\n| `SetCheckoutAddressRequest` | Set shipping/billing address | `first_name`, `last_name`, `address1`, `city`, `country_code`: required. `province_code`: required when applicable. `postal_code`: required. `phone`: nullable. |\n| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |\n| `CreateFulfillmentRequest` | Create fulfillment | `line_item_ids`: required, array of existing line item IDs. `tracking_company`: nullable, string. `tracking_number`: nullable, string. `tracking_url`: nullable, valid URL. |\n| `CreateRefundRequest` | Create refund | `line_items`: required, array with `id` and `quantity` per item. `reason`: required, string. `amount`: required, integer, min:0, max:original payment amount. |\n| `UpdateStoreSettingsRequest` | Update store settings | `name`: required, string. `default_currency`: in allowed currencies. `default_locale`: in allowed locales. `timezone`: valid timezone string. |\n| `InviteStaffRequest` | Invite staff member | `email`: required, valid email. `role`: required, in:admin,staff,support (not owner). |\n| `RegisterCustomerRequest` | Customer registration | `name`: required. `email`: required, email, unique per store (composite). `password`: required, min:8, confirmed. |\n| `LoginRequest` | Admin/customer login | `email`: required, email. `password`: required, string. |\n\n**Authorization in Form Requests:**\n\nEach Form Request's `authorize()` method must check the relevant policy or gate. For example, `StoreProductRequest::authorize()` checks whether the user can create a Product. If authorization fails, the request is rejected with 403 before validation even runs.\n\n---\n\n### 4.5 XSS Prevention\n\n| Context | Strategy |\n|---------|----------|\n| Standard Blade output | Double-curly-brace syntax auto-escapes all output (Laravel default) |\n| Rich HTML output | Unescaped syntax used only for trusted HTML fields: product `description_html`, page `body_html` |\n| HTML input sanitization | Use HTMLPurifier (or equivalent) on save, before persisting to database |\n| Livewire properties | Auto-escaped by default when rendered in Blade |\n| JSON API responses | Laravel escapes JSON output by default |\n\n#### HTML Sanitization Requirements\n\nAn `SanitizeHtml` action class must be created at `app/Actions/SanitizeHtml.php`.\n\n**Allowed HTML elements and attributes:**\n\n| Element | Allowed Attributes |\n|---------|-------------------|\n| `p`, `br`, `strong`, `em`, `u` | none |\n| `ol`, `ul`, `li` | none |\n| `a` | `href` |\n| `img` | `src`, `alt` |\n| `h1` through `h6` | none |\n| `blockquote` | none |\n| `table`, `thead`, `tbody`, `tr`, `th`, `td` | none |\n| `div`, `span` | none |\n\nAll other elements and attributes must be stripped. Empty elements should be removed.\n\n**Where sanitization is applied:** Before persisting product `description_html` and page `body_html` fields. The sanitization runs during the create and update operations for these models.\n\n---\n\n### 4.6 SQL Injection Prevention\n\n**Requirements:**\n\n- All database queries must use Eloquent ORM with parameterized bindings (Laravel default behavior).\n- No raw query strings may contain unsanitized user input.\n- Any use of `whereRaw`, `selectRaw`, or `DB::raw` must use parameter bindings (the `?` placeholder syntax).\n- FTS5 (full-text search) queries require special handling: user input must be sanitized before building the MATCH expression.\n\n#### FTS5 Input Sanitization\n\nBefore using user input in an FTS5 MATCH expression:\n\n1. Strip all characters that are not Unicode letters, Unicode numbers, or whitespace (remove FTS5 special operators like `*`, `AND`, `OR`, `NOT`, `NEAR`, quotes, parentheses, etc.).\n2. Trim leading and trailing whitespace.\n3. Pass the sanitized string via a parameterized binding (never concatenate into the query string).\n\n---\n\n### 4.7 Mass Assignment Protection\n\n**Requirements:**\n\n- Every Eloquent model must define a `$fillable` array listing only the columns that are safe for mass assignment.\n- The pattern `$guarded = []` is never permitted in any model.\n- All controller and Livewire action code must use `$request->validated()` (not `$request->all()` or `$request->input()`) when passing data to model creation or update methods.\n\n---\n\n### 4.8 Password Security\n\n| Setting | Value |\n|---------|-------|\n| Minimum length | 8 characters (for both admin and customer passwords) |\n| Hashing algorithm | Bcrypt (Laravel default) |\n| Password in logs | Never logged - use Laravel's built-in log redaction |\n| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |\n\n**Model requirements:**\n\n- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.\n- Password validation rules must enforce minimum 8 characters and use the `confirmed` rule (requiring a matching `password_confirmation` field).\n\n**Important:** Both the `User` and `Customer` models use `password_hash` as the database column name instead of Laravel's default `password`. Both models must override `getAuthPassword()`:\n\n```php\npublic function getAuthPassword(): string\n{\n    return $this->password_hash;\n}\n```\n\nThis is required for `Auth::attempt()` and `Hash::check()` to work correctly with the custom column name.\n\n---\n\n## 5. Session Configuration\n\nFile: `config/session.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `driver` | `'file'` | Simple, no external dependencies. Compatible with SQLite-based stack without needing Redis. |\n| `lifetime` | 120 (minutes) | Configurable via `SESSION_LIFETIME` env var. |\n| `expire_on_close` | `false` | Session persists until lifetime expires, not on browser close. |\n| `encrypt` | `true` | Session data encrypted at rest on disk. |\n| `cookie` | `'shop_session'` | Custom name to avoid generic fingerprinting. Configurable via `SESSION_COOKIE` env var. |\n| `path` | `'/'` | Cookie available across entire domain. |\n| `domain` | `null` | Laravel determines from request. |\n| `secure` | `true` | HTTPS-only cookies in production. Configurable via `SESSION_SECURE_COOKIE` env var (set to false for local dev). |\n| `http_only` | `true` | Cookie inaccessible to JavaScript (mitigates XSS-based cookie theft). |\n| `same_site` | `'lax'` | Allows navigation-initiated requests while preventing CSRF from external origins. |\n| `partitioned` | `false` | Not needed for this use case. |\n\n---\n\n## 6. CORS Configuration\n\nFile: `config/cors.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `paths` | `['api/*', 'sanctum/csrf-cookie']` | Only API and Sanctum CSRF cookie endpoints are CORS-enabled. Web routes are not. |\n| `allowed_methods` | `['*']` | All HTTP methods allowed for API. |\n| `allowed_origins` | `[FRONTEND_URL env var, default '*']` | Restrict to store domain in production. Wildcard for development. |\n| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |\n| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |\n| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |\n| `max_age` | `0` | No preflight caching (conservative default). |\n| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |\n\n---\n\n## 7. Webhook Signature Verification\n\n### 7.1 Inbound Payment Webhooks\n\n> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).\n\n---\n\n### 7.2 Platform Webhooks (Outbound)\n\nService: `App\\Services\\WebhookDispatcher`\n\nWhen the platform sends webhooks to external subscribers (third-party apps, integrations).\n\n```mermaid\nsequenceDiagram\n    participant App as Platform\n    participant Sub as Subscriber URL\n\n    App->>App: Serialize payload as JSON\n    App->>App: Generate Unix timestamp\n    App->>App: Concatenate: \"{timestamp}.{json_body}\"\n    App->>App: HMAC-SHA256(signing_secret, concatenated_message)\n    App->>Sub: POST to subscriber URL\n    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json\n    Sub-->>App: 200 OK (acknowledged)\n```\n\n**Requirements:**\n\n1. The payload is serialized as JSON.\n2. A Unix timestamp is generated (current time).\n3. The message to sign is: `\"{timestamp}.{json_body}\"` (timestamp, dot, JSON string).\n4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).\n5. The HTTP request to the subscriber includes three headers:\n   - `X-Platform-Signature`: the hex-encoded HMAC digest\n   - `X-Platform-Timestamp`: the Unix timestamp as a string\n   - `Content-Type`: `application/json`\n6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.\n\n---\n\n## 8. Audit Logging\n\n### 8.1 Logging Channel\n\nA dedicated `audit` channel must be added to `config/logging.php`.\n\n| Setting | Value |\n|---------|-------|\n| Driver | `daily` (file-based, daily rotation) |\n| Path | `storage/logs/audit.log` |\n| Level | `info` |\n| Retention | 90 days |\n\n---\n\n### 8.2 Log Entry Format\n\nAll audit log entries are structured JSON objects with the following schema:\n\n| Field | Type | Required | Description |\n|-------|------|:--------:|-------------|\n| `timestamp` | string (ISO-8601) | Y | When the event occurred |\n| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |\n| `user_id` | integer or null | N | ID of the admin user who triggered the event |\n| `store_id` | integer or null | N | ID of the store context |\n| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |\n| `resource_id` | integer or null | N | ID of the affected resource |\n| `ip` | string | Y | Client IP address |\n| `user_agent` | string | Y | Client user agent string |\n| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |\n| (additional keys) | varies | N | Event-specific extra data |\n\n---\n\n### 8.3 Events to Log\n\n#### Authentication Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `auth.login` | Successful admin login | user_id, ip, user_agent |\n| `auth.logout` | Admin logout | user_id |\n| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |\n| `customer.login` | Successful customer login | customer_id, store_id, ip |\n| `customer.registered` | New customer registration | customer_id, store_id |\n| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |\n\n#### Admin Resource Events\n\nFor each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:\n\n**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff\n\n| Event Pattern | Trigger | Extra Data |\n|---------------|---------|------------|\n| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |\n| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |\n| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |\n| `{resource}.restored` | Resource restored from soft-delete | resource_id |\n\n#### Security Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `api_token.created` | New API token issued | token_name, abilities list, user_id |\n| `api_token.revoked` | API token revoked | token_name, user_id |\n| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |\n| `store.settings_changed` | Store settings updated | changed fields with old/new values |\n\n---\n\n### 8.4 Audit Logger Service\n\nFile: `app/Services/AuditLogger.php`\n\nA service class that encapsulates audit log writing.\n\n**Method signature:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|:--------:|-------------|\n| `event` | string | Y | Event identifier |\n| `userId` | int or null | N | Acting user ID |\n| `storeId` | int or null | N | Store context ID |\n| `resourceType` | string or null | N | Affected resource type |\n| `resourceId` | int or null | N | Affected resource ID |\n| `extra` | associative array | N | Additional key-value pairs to include in the log entry |\n\n**Behavior:**\n\n1. Write to the `audit` logging channel at the `info` level.\n2. Automatically include `timestamp` (ISO-8601), `ip` (from request), and `user_agent` (from request) in every entry.\n3. Merge any `extra` data into the log context.\n\n---\n\n### 8.5 Integration with Laravel Events\n\nThe audit logger hooks into Laravel's built-in authentication events and custom model observers.\n\n#### Auth Event Listeners\n\nRegister listeners (in `AppServiceProvider` or `EventServiceProvider`) for these Laravel events:\n\n| Laravel Event | Audit Event ID | Data Captured |\n|--------------|----------------|---------------|\n| `Illuminate\\Auth\\Events\\Login` | `auth.login` | user_id from event |\n| `Illuminate\\Auth\\Events\\Failed` | `auth.failed_login` | email from credentials (never password) |\n| `Illuminate\\Auth\\Events\\Logout` | `auth.logout` | user_id from event |\n\n#### Model Observers\n\nFor each auditable resource model, create an observer (or use a shared trait) that logs `created`, `updated`, and `deleted` events. The `updated` observer should capture the changed fields (dirty attributes) with their original and new values.\n\n---\n\n## 9. Security Checklist Summary\n\n| # | Control | Implementation Approach | Priority |\n|---|---------|------------------------|----------|\n| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |\n| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |\n| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |\n| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |\n| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |\n| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |\n","structuredPatch":[{"oldStart":1105,"oldLines":6,"newStart":1105,"newLines":6,"lines":[" | 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |"," | 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |"," | 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |","-| 14 | Webhook signature verification | Stripe SDK verification, PayPal API verification, HMAC-SHA256 for outbound | Required |","+| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |"," | 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |"," | 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"0569146a-8f91-4d10-b7e3-088585fafe3c"}
{"parentUuid":"90a709eb-617a-4c96-a460-884868792f86","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01G32ongf1zVngPVtkjP3SxY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NoeFFjWQRgH37TkPuqEcFY","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","offset":1094,"limit":12}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":255,"cache_read_input_tokens":162012,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":255},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X7q1zt5jJKVfeisnM9","type":"assistant","uuid":"e060e675-f11e-4a91-927d-610b7ea7545c","timestamp":"2026-02-11T15:29:47.789Z"}
{"parentUuid":"e060e675-f11e-4a91-927d-610b7ea7545c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01NoeFFjWQRgH37TkPuqEcFY","toolUseID":"toolu_01NoeFFjWQRgH37TkPuqEcFY","timestamp":"2026-02-11T15:29:47.792Z","uuid":"1cd6f138-2093-4830-8dab-69c31439f4ed"}
{"parentUuid":"e060e675-f11e-4a91-927d-610b7ea7545c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NoeFFjWQRgH37TkPuqEcFY","type":"tool_result","content":"  1094→|---|---------|------------------------|----------|\n  1095→| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n  1096→| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n  1097→| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n  1098→| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n  1099→| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n  1100→| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n  1101→| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n  1102→| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n  1103→| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n  1104→| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n  1105→| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |"}]},"uuid":"2ed040b8-81b3-40a6-ac13-e97a14636c6e","timestamp":"2026-02-11T15:29:47.791Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"|---|---------|------------------------|----------|\n| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |","numLines":12,"startLine":1094,"totalLines":1111}},"sourceToolAssistantUUID":"e060e675-f11e-4a91-927d-610b7ea7545c"}
{"parentUuid":"2ed040b8-81b3-40a6-ac13-e97a14636c6e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01DijjhSJpCjb5c5DoJvhZDD","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me also fix the audit log entry."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":512,"cache_read_input_tokens":162267,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":512},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X84Ru1pYzqtMoLsHck","type":"assistant","uuid":"a00ad109-e9e0-47ff-b287-bd7764df5e4a","timestamp":"2026-02-11T15:29:50.822Z"}
{"parentUuid":"a00ad109-e9e0-47ff-b287-bd7764df5e4a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01DijjhSJpCjb5c5DoJvhZDD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017UpdMaekAW4LMdDTCXERvt","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","old_string":"| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |","new_string":"| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":512,"cache_read_input_tokens":162267,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":512},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X84Ru1pYzqtMoLsHck","type":"assistant","uuid":"f1f76531-8c64-48a2-bb46-721bcf8842ff","timestamp":"2026-02-11T15:29:52.961Z"}
{"parentUuid":"f1f76531-8c64-48a2-bb46-721bcf8842ff","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_017UpdMaekAW4LMdDTCXERvt","toolUseID":"toolu_017UpdMaekAW4LMdDTCXERvt","timestamp":"2026-02-11T15:29:52.967Z","uuid":"bf458711-7dc6-4638-9fed-95dbe38c690e"}
{"parentUuid":"f1f76531-8c64-48a2-bb46-721bcf8842ff","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017UpdMaekAW4LMdDTCXERvt","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md has been updated successfully."}]},"uuid":"4c48e8a2-df0e-4aa7-962a-fa9978d57698","timestamp":"2026-02-11T15:29:52.966Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","oldString":"| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |","newString":"| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |","originalFile":"# 06 - Authentication and Security Specification\n\n> Shopify-clone e-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite\n\n---\n\n## 1. Authentication\n\nThe platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.\n\n### 1.1 Admin Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `web` (default Laravel guard) |\n| Provider name | `users` (Eloquent driver) |\n| Model | `App\\Models\\User` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /admin/login` |\n| Login form | Livewire component `Admin\\Auth\\Login` |\n\n#### Admin Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (web)\n    participant DB as Database\n    participant S as Session\n\n    B->>LW: Submit email + password\n    LW->>LW: Validate input (email required, password required)\n    LW->>LW: Check rate limiter (5/min per IP)\n    alt Rate limit exceeded\n        LW-->>B: Flash \"Too many attempts. Try again in X seconds.\"\n    end\n    LW->>Auth: attempt(credentials, remember)\n    Auth->>DB: SELECT from users WHERE email = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Credentials valid\n        Auth->>S: Regenerate session ID (prevent fixation)\n        Auth-->>LW: Success\n        LW-->>B: Redirect to /admin (dashboard)\n    else Credentials invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\" (generic, never reveal which field)\n    end\n```\n\n**Requirements:**\n\n- The login form collects `email` and `password`.\n- On successful login, the session MUST be regenerated to prevent session fixation attacks.\n- On failed login, only the generic message \"Invalid credentials\" is shown. The system must not indicate whether the email or password was wrong.\n- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.\n- After login: redirect to `/admin` (the dashboard).\n\n#### Remember Me\n\n- An optional \"Remember me\" checkbox on the login form.\n- When enabled, Laravel sets a long-lived remember token cookie.\n- The `remember_token` column on the `users` table stores the hashed token.\n- Cookie name follows Laravel default convention: `remember_web_{hash}`.\n\n#### Admin Password Reset Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant App as Application\n    participant DB as Database\n    participant Mail as Mail Service\n\n    B->>App: GET /admin/forgot-password\n    App-->>B: Render forgot-password form\n    B->>App: POST /admin/forgot-password (email)\n    App->>DB: Look up user by email\n    alt User exists\n        App->>DB: Store reset token in password_reset_tokens table\n        App->>Mail: Send reset link email\n    end\n    App-->>B: \"If that email exists, we sent a reset link.\" (generic)\n    B->>App: GET /admin/reset-password/{token}\n    App-->>B: Render reset-password form\n    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)\n    App->>DB: Validate token (exists, not expired, matches email)\n    alt Valid token\n        App->>DB: Update user password (bcrypt hash)\n        App->>DB: Delete used token\n        App-->>B: Redirect to /admin/login with success flash\n    else Invalid/expired token\n        App-->>B: Error message\n    end\n```\n\n**Requirements:**\n\n- Uses Laravel's built-in password broker (`users` broker).\n- Reset tokens stored in the `password_reset_tokens` table.\n- Token expiration: 60 minutes (configurable in `config/auth.php` under `passwords.users.expire`).\n- Throttle: one reset email per 60 seconds per email address.\n- The \"forgot password\" response must be generic (do not reveal whether email exists).\n\n#### Admin Logout\n\n- Route: `POST /admin/logout` (via Livewire action or standard route).\n- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.\n- The response must not cache.\n\n#### Session Lifetime\n\n| Setting | Value |\n|---------|-------|\n| Lifetime | 120 minutes (configurable via `SESSION_LIFETIME` env var) |\n| Expire on close | No (session persists until lifetime expires) |\n\n---\n\n### 1.2 Customer Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `customer` (custom guard) |\n| Provider name | `customers` (Eloquent driver) |\n| Model | `App\\Models\\Customer` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /account/login` |\n| Registration route | `GET /account/register` |\n\n#### Customer Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (customer)\n    participant Store as ResolveStore Middleware\n    participant DB as Database\n\n    Note over Store: Store already resolved from hostname\n    B->>LW: Submit email + password\n    LW->>LW: Validate input\n    LW->>LW: Check rate limiter (5/min per IP)\n    LW->>Auth: attempt(credentials + store_id, remember)\n    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Valid\n        Auth-->>LW: Success (regenerate session)\n        LW-->>B: Redirect to /account (or intended URL)\n    else Invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\"\n    end\n```\n\n**Requirements:**\n\n- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.\n- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.\n- The same email address can exist in different stores (multi-tenant isolation).\n- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.\n- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).\n\n#### Customer Registration Flow\n\n```mermaid\nflowchart TD\n    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]\n    B --> C{Validate via RegisterCustomerRequest}\n    C -- Invalid --> D[Show validation errors]\n    C -- Valid --> E[Create Customer record scoped to current store]\n    E --> F[Auto-login via customer guard]\n    F --> G[Redirect to /account]\n```\n\n**Registration fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|:--------:|------------|\n| name | string | Y | max:255 |\n| email | string | Y | valid email, unique per store (composite unique on email + store_id) |\n| password | string | Y | min:8, confirmed |\n| password_confirmation | string | Y | must match password |\n| marketing_opt_in | boolean | N | defaults to false |\n\n#### Customer Store Scoping\n\n- The `customers` auth provider requires a custom `CustomerUserProvider` class.\n- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.\n- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).\n- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.\n\n#### Customer Password Reset\n\n- Uses a separate password broker: `customers` broker.\n- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).\n- Token records include `store_id` to scope resets to the correct store.\n- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.\n- Token expiration: 60 minutes.\n- Throttle: one reset email per 60 seconds.\n- Generic response on the \"forgot password\" form (do not reveal whether email exists in the store).\n\n---\n\n### 1.3 API Authentication (Sanctum)\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `sanctum` |\n| Provider | `users` (admin users only) |\n| Token type | Personal access tokens |\n| Token prefix | `shop_` |\n| Hashing | SHA-256 |\n| Default expiration | 1 year |\n| Storage | `personal_access_tokens` table |\n\n#### Token Lifecycle\n\n```mermaid\nstateDiagram-v2\n    [*] --> Created: Admin creates token in Developers section\n    Created --> Active: Plain-text shown once, hash stored\n    Active --> InUse: Token sent as Bearer header\n    InUse --> Active: Request processed\n    Active --> Revoked: Admin revokes token\n    Active --> Expired: Expiration date reached\n    Revoked --> [*]: Token row deleted\n    Expired --> [*]: Token row deleted (cleanup job)\n```\n\n**Requirements:**\n\n- Tokens are created in the admin panel, under the Developers section.\n- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).\n- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.\n- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.\n- Sanctum middleware validates the hash and resolves the associated user.\n- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).\n- Revoking a token deletes its row from the `personal_access_tokens` table.\n\n#### Token Abilities (Scopes)\n\n| Ability | Description |\n|---------|-------------|\n| `read-products` | List and view products |\n| `write-products` | Create, update, delete products |\n| `read-orders` | List and view orders |\n| `write-orders` | Update orders, create fulfillments |\n| `read-customers` | List and view customers |\n| `write-customers` | Update customers |\n| `read-collections` | List and view collections |\n| `write-collections` | Create, update, delete collections |\n| `read-discounts` | List and view discounts |\n| `write-discounts` | Create, update, delete discounts |\n| `read-analytics` | View analytics data |\n| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |\n| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |\n| `read-themes` | View themes and theme files |\n| `write-themes` | Create, update, delete, publish themes |\n| `read-content` | View pages and navigation menus |\n| `write-content` | Create, update, delete pages and navigation items |\n| `manage-platform` | Platform-level management (super-admin only) |\n\nEach API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.\n\n---\n\n### 1.4 Guard Configuration\n\nFile to configure: `config/auth.php`\n\n#### Guards Data Dictionary\n\n| Guard Name | Driver | Provider | Purpose |\n|------------|--------|----------|---------|\n| `web` | session | `users` | Admin panel session auth (default guard) |\n| `customer` | session | `customers` | Storefront customer session auth |\n| `sanctum` | sanctum | `users` | Admin REST API token auth |\n\n#### Providers Data Dictionary\n\n| Provider Name | Driver | Model | Purpose |\n|---------------|--------|-------|---------|\n| `users` | eloquent | `App\\Models\\User` | Admin users |\n| `customers` | eloquent | `App\\Models\\Customer` | Storefront customers (store-scoped via custom provider) |\n\n#### Password Brokers Data Dictionary\n\n| Broker Name | Provider | Token Table | Expiry | Throttle |\n|-------------|----------|-------------|--------|----------|\n| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |\n| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |\n\n**Defaults:** The default guard is `web` and the default password broker is `users`.\n\n---\n\n## 2. Authorization\n\n### 2.1 Role Enum\n\nFile: `app/Enums/StoreUserRole.php`\n\nA backed string enum with four values:\n\n| Case | Stored Value | Description |\n|------|-------------|-------------|\n| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |\n| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |\n| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |\n| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |\n\n**Constraints:**\n\n- Each store must have exactly one Owner.\n- Ownership transfer is a dedicated action (not a simple role change).\n- A User can hold different roles in different stores (via the `store_users` pivot table).\n\n---\n\n### 2.2 Permission Matrix\n\nThe `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.\n\n| Resource | Action | Owner | Admin | Staff | Support |\n|----------|--------|:-----:|:-----:|:-----:|:-------:|\n| Store Settings | View | Y | Y | N | N |\n| Store Settings | Update | Y | Y | N | N |\n| Store | Delete | Y | N | N | N |\n| Products | List / View | Y | Y | Y | Y |\n| Products | Create | Y | Y | Y | N |\n| Products | Update | Y | Y | Y | N |\n| Products | Delete / Archive | Y | Y | N | N |\n| Products | Restore | Y | Y | N | N |\n| Collections | List / View | Y | Y | Y | Y |\n| Collections | Create | Y | Y | Y | N |\n| Collections | Update | Y | Y | Y | N |\n| Collections | Delete | Y | Y | N | N |\n| Orders | List / View | Y | Y | Y | Y |\n| Orders | Update | Y | Y | Y | N |\n| Orders | Cancel | Y | Y | N | N |\n| Fulfillments | Create | Y | Y | Y | N |\n| Fulfillments | Update / Cancel | Y | Y | Y | N |\n| Refunds | Create | Y | Y | N | N |\n| Customers | List / View | Y | Y | Y | Y |\n| Customers | Update | Y | Y | Y | N |\n| Discounts | List / View | Y | Y | Y | Y |\n| Discounts | Create | Y | Y | Y | N |\n| Discounts | Update | Y | Y | Y | N |\n| Discounts | Delete | Y | Y | N | N |\n| Themes | List / View | Y | Y | N | N |\n| Themes | Create / Upload | Y | Y | N | N |\n| Themes | Customize | Y | Y | N | N |\n| Themes | Publish | Y | Y | N | N |\n| Themes | Delete | Y | Y | N | N |\n| Pages | List / View | Y | Y | Y | N |\n| Pages | Create | Y | Y | Y | N |\n| Pages | Update | Y | Y | Y | N |\n| Pages | Delete | Y | Y | N | N |\n| Navigation | List / View | Y | Y | Y | N |\n| Navigation | Manage | Y | Y | N | N |\n| Apps | List / View | Y | Y | N | N |\n| Apps | Install / Remove | Y | Y | N | N |\n| Analytics | View | Y | Y | Y | N |\n| Developers / API | Manage | Y | Y | N | N |\n| Staff | List / View | Y | Y | N | N |\n| Staff | Invite | Y | Y | N | N |\n| Staff | Update Role | Y | Y | N | N |\n| Staff | Remove | Y | Y | N | N |\n| Shipping Zones | Manage | Y | Y | N | N |\n| Tax Settings | Manage | Y | Y | N | N |\n| Search Settings | Manage | Y | Y | N | N |\n\n#### Role Group Shorthand\n\nThe permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:\n\n| Group Name | Roles Included | Used For |\n|------------|---------------|----------|\n| OwnerOnly | Owner | Store deletion, ownership transfer |\n| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |\n| OwnerAdminOrStaff | Owner, Admin, Staff | Create/update products, collections, discounts, pages, fulfillments, order updates |\n| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |\n\n---\n\n### 2.3 Role Checking Trait\n\nFile: `app/Traits/ChecksStoreRole.php`\n\nA reusable trait included in all policies and used by gate definitions. Must provide the following methods:\n\n| Method | Parameters | Returns | Description |\n|--------|-----------|---------|-------------|\n| `getStoreRole` | User, store_id (int) | StoreUserRole or null | Looks up the user's role for the given store via the `store_users` relationship. Returns null if the user has no role in that store. |\n| `hasRole` | User, store_id (int), roles (array of StoreUserRole) | bool | Returns true if the user's role for the store is in the provided list. Returns false if user has no role. |\n| `isOwnerOrAdmin` | User, store_id (int) | bool | Shorthand: checks if role is Owner or Admin. |\n| `isOwnerAdminOrStaff` | User, store_id (int) | bool | Shorthand: checks if role is Owner, Admin, or Staff. |\n| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |\n\n**Store ID resolution:** When the policy method receives a model instance (e.g., Product), the store_id comes from the model's `store_id` attribute. When the policy method does not receive a model (e.g., `viewAny`, `create`), the store_id is resolved from the container-bound `current_store`.\n\n---\n\n### 2.4 Laravel Policies\n\nAll policies use the `ChecksStoreRole` trait. Laravel 12 auto-discovers policies by convention (`App\\Policies\\{Model}Policy`), so no manual registration is needed.\n\nEvery policy method follows this pattern:\n1. Determine the `store_id` (from the model if available, otherwise from the resolved current store).\n2. Use the appropriate role-checking helper method.\n3. Return a boolean.\n\n#### ProductPolicy\n\nFile: `app/Policies/ProductPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Product)` | Yes | AnyRole | product.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Product)` | Yes | OwnerAdminOrStaff | product.store_id |\n| `delete(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `archive(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `restore(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n\n#### OrderPolicy\n\nFile: `app/Policies/OrderPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Order)` | Yes | AnyRole | order.store_id |\n| `update(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `cancel(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n| `createFulfillment(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `createRefund(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n\n#### CollectionPolicy\n\nFile: `app/Policies/CollectionPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Collection)` | Yes | AnyRole | collection.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Collection)` | Yes | OwnerAdminOrStaff | collection.store_id |\n| `delete(User, Collection)` | Yes | OwnerOrAdmin | collection.store_id |\n\n#### DiscountPolicy\n\nFile: `app/Policies/DiscountPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Discount)` | Yes | AnyRole | discount.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Discount)` | Yes | OwnerAdminOrStaff | discount.store_id |\n| `delete(User, Discount)` | Yes | OwnerOrAdmin | discount.store_id |\n\n#### CustomerPolicy\n\nFile: `app/Policies/CustomerPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Customer)` | Yes | AnyRole | customer.store_id |\n| `update(User, Customer)` | Yes | OwnerAdminOrStaff | customer.store_id |\n\n#### ThemePolicy\n\nFile: `app/Policies/ThemePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerOrAdmin | current_store |\n| `view(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `create(User)` | No | OwnerOrAdmin | current_store |\n| `update(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `delete(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `publish(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n\n#### PagePolicy\n\nFile: `app/Policies/PagePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `view(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `delete(User, Page)` | Yes | OwnerOrAdmin | page.store_id |\n\n#### StorePolicy\n\nFile: `app/Policies/StorePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `updateSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `delete(User, Store)` | Yes | OwnerOnly | store.id |\n\n#### FulfillmentPolicy\n\nFile: `app/Policies/FulfillmentPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerAdminOrStaff | order.store_id |\n| `update(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n| `cancel(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n\nNote: `create` receives the parent Order, not a Fulfillment, since the Fulfillment does not exist yet. The `update` and `cancel` methods navigate through the Fulfillment's order relationship to get the store_id.\n\n#### RefundPolicy\n\nFile: `app/Policies/RefundPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerOrAdmin | order.store_id |\n\nNote: `create` receives the parent Order for the same reason as FulfillmentPolicy.\n\n#### NavigationMenuPolicy\n\nFile: `app/Policies/NavigationMenuPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `manage(User)` | No | OwnerOrAdmin | current_store |\n\n---\n\n### 2.5 Gates (Non-Model Operations)\n\nGates are registered in `AppServiceProvider` (or a dedicated `AuthServiceProvider`). Each gate resolves the current store from the container and checks the user's role via the `store_users` relationship.\n\n| Gate Name | Required Roles | Purpose |\n|-----------|---------------|---------|\n| `manage-store-settings` | Owner, Admin | Access store settings pages |\n| `manage-staff` | Owner, Admin | Invite, update, remove staff members |\n| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |\n| `view-analytics` | Owner, Admin, Staff | View analytics dashboard and reports |\n| `manage-shipping` | Owner, Admin | Manage shipping zones, rates, profiles |\n| `manage-taxes` | Owner, Admin | Manage tax settings and rates |\n| `manage-search-settings` | Owner, Admin | Configure search weights and synonyms |\n| `manage-navigation` | Owner, Admin | Create/edit/delete navigation menus |\n| `manage-apps` | Owner, Admin | Install and remove third-party apps |\n\n**Gate resolution pattern (all gates follow this):**\n\n1. Resolve the current store from the container\n2. Look up the user's `store_users` record for that store\n3. If no record exists, return false\n4. If the user's role is in the gate's required roles list, return true\n5. Otherwise return false\n\n---\n\n### 2.6 Policy and Gate Usage Patterns\n\n#### In Livewire Components\n\n- List views: call `authorize('viewAny', ModelClass)` in the `mount()` lifecycle method.\n- Action methods (delete, update, etc.): call `authorize('action', $modelInstance)` at the start of the action, before performing any mutation.\n- If authorization fails, Laravel throws `AuthorizationException`, which renders as 403.\n\n#### In API Controllers\n\n- Use `$this->authorize('action', $model)` at the start of each controller method.\n- Alternatively, use `can` middleware on routes for simpler checks.\n- Form Requests also implement `authorize()` to check the relevant policy or gate before validation runs.\n\n#### In Blade Templates\n\n- Use `@can('gate-name')` / `@cannot` directives to conditionally show/hide navigation items and action buttons.\n- This is purely cosmetic (UI hiding) - the actual authorization check happens server-side in policies/gates.\n\n---\n\n## 3. Middleware Stack\n\n### 3.1 Middleware Registration\n\nFile: `bootstrap/app.php`\n\nThree custom middleware aliases must be registered:\n\n| Alias | Class | Purpose |\n|-------|-------|---------|\n| `store.resolve` | `App\\Http\\Middleware\\ResolveStore` | Resolves current store from hostname or session |\n| `role.check` | `App\\Http\\Middleware\\CheckStoreRole` | Checks user has required role for current store |\n| `auth:customer` | `App\\Http\\Middleware\\CustomerAuthenticate` | Ensures customer is authenticated via customer guard |\n\n---\n\n### 3.2 Route Middleware Groups\n\n| Route Group | Middleware Stack | Description |\n|-------------|-----------------|-------------|\n| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |\n\n---\n\n### 3.3 Custom Middleware Specifications\n\n#### ResolveStore\n\nFile: `app/Http/Middleware/ResolveStore.php`\n\n**Purpose:** Resolves the current store from the request hostname and binds it to the service container.\n\n```mermaid\nflowchart TD\n    A[Extract hostname from request] --> B{Route type?}\n    B -- Storefront --> C{Match hostname in store_domains table?}\n    C -- Yes --> F[Store found]\n    C -- No --> E[Abort 404: Store not found]\n    F --> G{Store status == suspended?}\n    G -- Yes --> H[Abort 503: Store unavailable]\n    G -- No --> I[Bind Store to container as 'current_store']\n    I --> J[Share Store with all views as 'currentStore']\n    J --> K[Continue to next middleware]\n    B -- Admin --> L[Read store_id from session 'current_store_id']\n    L --> M{User has store_users record?}\n    M -- No --> N[Abort 403]\n    M -- Yes --> I\n```\n\n**Requirements:**\n\nStorefront requests: Resolve store from the `store_domains` table by matching the request hostname against `store_domains.hostname`. Cache the hostname-to-store mapping using file cache (tag: `store-domains`).\n\nAdmin requests: Store is determined by session key `current_store_id`, set after login or via the store-switcher in the admin panel. Admin routes do NOT use hostname resolution. The `admin.acme-fashion.test` domain in seeders routes requests to the admin panel, but the actual store context within the admin is determined by the session `current_store_id` after login.\n\n**Shared steps (after store is resolved):**\n\n1. If the store's `status` column is `'suspended'`: abort with HTTP 503 and message \"This store is currently unavailable.\" (For admin routes: abort 403 for mutations instead.)\n2. Bind the resolved Store model instance to the service container with the key `'current_store'`.\n3. Share the store with all Blade views under the variable name `currentStore`.\n4. Allow the request to proceed.\n\n#### CheckStoreRole\n\nFile: `app/Http/Middleware/CheckStoreRole.php`\n\n**Purpose:** Verifies the authenticated user has one of the specified roles for the current store.\n\n**Route usage:** Applied as `role.check:owner,admin` or `role.check:owner,admin,staff` (comma-separated role values passed as variadic parameters).\n\n```mermaid\nflowchart TD\n    A[Resolve current_store from container] --> B[Get authenticated user]\n    B --> C[Look up store_users record for user + store]\n    C --> D{Record exists?}\n    D -- No --> E[Abort 403: No access to this store]\n    D -- Yes --> F[Parse allowed roles from middleware parameters]\n    F --> G{User's role in allowed list?}\n    G -- No --> H[Abort 403: Insufficient permissions]\n    G -- Yes --> I[Attach store_user to request attributes]\n    I --> J[Continue to next middleware]\n```\n\n**Requirements:**\n\n1. Resolve the current store from the container.\n2. Get the authenticated user from the request.\n3. Query the user's `store_users` relationship filtered by `store_id`.\n4. If no record exists: abort 403 with message \"You do not have access to this store.\"\n5. Convert the middleware parameter strings to `StoreUserRole` enum instances.\n6. If the user's role is not in the allowed list: abort 403 with message \"Insufficient permissions.\"\n7. Store the resolved `StoreUser` pivot record on the request attributes (key: `store_user`) for downstream use.\n8. Allow the request to proceed.\n\n#### CustomerAuthenticate\n\nFile: `app/Http/Middleware/CustomerAuthenticate.php`\n\n**Purpose:** Ensures the request is authenticated via the `customer` guard. Redirects unauthenticated customers to login.\n\n```mermaid\nflowchart TD\n    A[Check customer guard authentication] --> B{Authenticated?}\n    B -- Yes --> C[Continue to next middleware]\n    B -- No --> D[Store current URL as intended redirect]\n    D --> E[Redirect to /account/login]\n```\n\n**Requirements:**\n\n1. Check if `Auth::guard('customer')` reports the user as authenticated.\n2. If authenticated: allow the request to proceed.\n3. If not authenticated: save the current URL in the session as the intended URL (so the customer returns there after login), then redirect to the storefront login route (`/account/login`).\n\n---\n\n## 4. Security Controls\n\n### 4.1 CSRF Protection\n\n| Context | Mechanism |\n|---------|-----------|\n| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |\n| Livewire actions | Livewire handles CSRF verification automatically on every request |\n| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |\n| SPA clients | `GET /sanctum/csrf-cookie` sets XSRF-TOKEN cookie for session-based API calls |\n\n**Requirements:**\n\n- CSRF verification is enforced globally on all `web` middleware group routes.\n- No routes within the `web` group may be excluded from CSRF verification.\n- API routes (under `api` middleware group) are inherently exempt since they use token auth.\n\n---\n\n### 4.2 Rate Limiting\n\nAll rate limiters are defined in `bootstrap/app.php` (or `AppServiceProvider::boot()`).\n\n| Limiter Name | Limit | Keyed By | Applied To |\n|-------------|-------|----------|------------|\n| `login` | 5 per minute | IP address | Admin login, customer login |\n| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |\n| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |\n| `checkout` | 10 per minute | Session ID | Checkout-related operations |\n| `search` | 30 per minute | IP address | Search queries |\n| `analytics` | 60 per minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |\n\n**Rate limit response headers** (returned on all API responses by Laravel default):\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429 responses) |\n\nWhen a rate limit is exceeded, the response is HTTP 429 Too Many Requests.\n\n---\n\n### 4.3 Encryption at Rest\n\nEncryption algorithm: AES-256-CBC with MAC verification, keyed by the application's `APP_KEY`.\n\n**Fields requiring encrypted storage (using Laravel's `encrypted` cast):**\n\n| Model | Column | Cast Type | Purpose |\n|-------|--------|-----------|---------|\n| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |\n| WebhookSubscription | `signing_secret_encrypted` | `encrypted` | Per-subscription signing secrets for outbound webhooks |\n| Payment | `raw_json` | `encrypted:array` | Full payment gateway response (may contain sensitive card/account data) |\n\n**Requirements:**\n\n- These columns are declared with the `encrypted` (or `encrypted:array`) cast in the model's `casts()` method.\n- Data is automatically encrypted before writing to the database and decrypted when reading.\n- The encrypted columns store opaque ciphertext (type TEXT in SQLite). They cannot be queried with WHERE clauses at the database level.\n\n---\n\n### 4.4 Input Validation\n\nAll write endpoints must use dedicated Form Request classes. Validation rules use string syntax per project convention.\n\n#### Form Request Inventory\n\n| Form Request Class | Used By | Key Validation Rules |\n|-------------------|---------|---------------------|\n| `StoreProductRequest` | Create product | `title`: required, string, max:255. `description_html`: nullable, string. `status`: required, in:active,draft,archived. `vendor`: nullable, string. Price fields: required, integer, min:0. |\n| `UpdateProductRequest` | Update product | Same fields as StoreProductRequest but all optional (sometimes rule). |\n| `StoreCollectionRequest` | Create collection | `title`: required, string, max:255. `type`: required, in:manual,automated. `rules_json`: required_if:type,automated, valid JSON. |\n| `UpdateCollectionRequest` | Update collection | Same as StoreCollectionRequest, all optional. |\n| `StoreDiscountRequest` | Create discount | `type`: required, in:code,automatic. `code`: required_if:type,code, string, max:50, unique per store (case-insensitive). `value_type`: required, in:fixed,percent,free_shipping. `value_amount`: required_unless:value_type,free_shipping, integer, min:0; when value_type is `percent`, max:100. `minimum_order_amount`: nullable, integer, min:0. `usage_limit`: nullable, integer, min:1. `starts_at`: nullable, date, ISO-8601. `ends_at`: nullable, date, after:starts_at. |\n| `UpdateDiscountRequest` | Update discount | Same as StoreDiscountRequest, code unique ignoring self. |\n| `SetCheckoutAddressRequest` | Set shipping/billing address | `first_name`, `last_name`, `address1`, `city`, `country_code`: required. `province_code`: required when applicable. `postal_code`: required. `phone`: nullable. |\n| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |\n| `CreateFulfillmentRequest` | Create fulfillment | `line_item_ids`: required, array of existing line item IDs. `tracking_company`: nullable, string. `tracking_number`: nullable, string. `tracking_url`: nullable, valid URL. |\n| `CreateRefundRequest` | Create refund | `line_items`: required, array with `id` and `quantity` per item. `reason`: required, string. `amount`: required, integer, min:0, max:original payment amount. |\n| `UpdateStoreSettingsRequest` | Update store settings | `name`: required, string. `default_currency`: in allowed currencies. `default_locale`: in allowed locales. `timezone`: valid timezone string. |\n| `InviteStaffRequest` | Invite staff member | `email`: required, valid email. `role`: required, in:admin,staff,support (not owner). |\n| `RegisterCustomerRequest` | Customer registration | `name`: required. `email`: required, email, unique per store (composite). `password`: required, min:8, confirmed. |\n| `LoginRequest` | Admin/customer login | `email`: required, email. `password`: required, string. |\n\n**Authorization in Form Requests:**\n\nEach Form Request's `authorize()` method must check the relevant policy or gate. For example, `StoreProductRequest::authorize()` checks whether the user can create a Product. If authorization fails, the request is rejected with 403 before validation even runs.\n\n---\n\n### 4.5 XSS Prevention\n\n| Context | Strategy |\n|---------|----------|\n| Standard Blade output | Double-curly-brace syntax auto-escapes all output (Laravel default) |\n| Rich HTML output | Unescaped syntax used only for trusted HTML fields: product `description_html`, page `body_html` |\n| HTML input sanitization | Use HTMLPurifier (or equivalent) on save, before persisting to database |\n| Livewire properties | Auto-escaped by default when rendered in Blade |\n| JSON API responses | Laravel escapes JSON output by default |\n\n#### HTML Sanitization Requirements\n\nAn `SanitizeHtml` action class must be created at `app/Actions/SanitizeHtml.php`.\n\n**Allowed HTML elements and attributes:**\n\n| Element | Allowed Attributes |\n|---------|-------------------|\n| `p`, `br`, `strong`, `em`, `u` | none |\n| `ol`, `ul`, `li` | none |\n| `a` | `href` |\n| `img` | `src`, `alt` |\n| `h1` through `h6` | none |\n| `blockquote` | none |\n| `table`, `thead`, `tbody`, `tr`, `th`, `td` | none |\n| `div`, `span` | none |\n\nAll other elements and attributes must be stripped. Empty elements should be removed.\n\n**Where sanitization is applied:** Before persisting product `description_html` and page `body_html` fields. The sanitization runs during the create and update operations for these models.\n\n---\n\n### 4.6 SQL Injection Prevention\n\n**Requirements:**\n\n- All database queries must use Eloquent ORM with parameterized bindings (Laravel default behavior).\n- No raw query strings may contain unsanitized user input.\n- Any use of `whereRaw`, `selectRaw`, or `DB::raw` must use parameter bindings (the `?` placeholder syntax).\n- FTS5 (full-text search) queries require special handling: user input must be sanitized before building the MATCH expression.\n\n#### FTS5 Input Sanitization\n\nBefore using user input in an FTS5 MATCH expression:\n\n1. Strip all characters that are not Unicode letters, Unicode numbers, or whitespace (remove FTS5 special operators like `*`, `AND`, `OR`, `NOT`, `NEAR`, quotes, parentheses, etc.).\n2. Trim leading and trailing whitespace.\n3. Pass the sanitized string via a parameterized binding (never concatenate into the query string).\n\n---\n\n### 4.7 Mass Assignment Protection\n\n**Requirements:**\n\n- Every Eloquent model must define a `$fillable` array listing only the columns that are safe for mass assignment.\n- The pattern `$guarded = []` is never permitted in any model.\n- All controller and Livewire action code must use `$request->validated()` (not `$request->all()` or `$request->input()`) when passing data to model creation or update methods.\n\n---\n\n### 4.8 Password Security\n\n| Setting | Value |\n|---------|-------|\n| Minimum length | 8 characters (for both admin and customer passwords) |\n| Hashing algorithm | Bcrypt (Laravel default) |\n| Password in logs | Never logged - use Laravel's built-in log redaction |\n| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |\n\n**Model requirements:**\n\n- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.\n- Password validation rules must enforce minimum 8 characters and use the `confirmed` rule (requiring a matching `password_confirmation` field).\n\n**Important:** Both the `User` and `Customer` models use `password_hash` as the database column name instead of Laravel's default `password`. Both models must override `getAuthPassword()`:\n\n```php\npublic function getAuthPassword(): string\n{\n    return $this->password_hash;\n}\n```\n\nThis is required for `Auth::attempt()` and `Hash::check()` to work correctly with the custom column name.\n\n---\n\n## 5. Session Configuration\n\nFile: `config/session.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `driver` | `'file'` | Simple, no external dependencies. Compatible with SQLite-based stack without needing Redis. |\n| `lifetime` | 120 (minutes) | Configurable via `SESSION_LIFETIME` env var. |\n| `expire_on_close` | `false` | Session persists until lifetime expires, not on browser close. |\n| `encrypt` | `true` | Session data encrypted at rest on disk. |\n| `cookie` | `'shop_session'` | Custom name to avoid generic fingerprinting. Configurable via `SESSION_COOKIE` env var. |\n| `path` | `'/'` | Cookie available across entire domain. |\n| `domain` | `null` | Laravel determines from request. |\n| `secure` | `true` | HTTPS-only cookies in production. Configurable via `SESSION_SECURE_COOKIE` env var (set to false for local dev). |\n| `http_only` | `true` | Cookie inaccessible to JavaScript (mitigates XSS-based cookie theft). |\n| `same_site` | `'lax'` | Allows navigation-initiated requests while preventing CSRF from external origins. |\n| `partitioned` | `false` | Not needed for this use case. |\n\n---\n\n## 6. CORS Configuration\n\nFile: `config/cors.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `paths` | `['api/*', 'sanctum/csrf-cookie']` | Only API and Sanctum CSRF cookie endpoints are CORS-enabled. Web routes are not. |\n| `allowed_methods` | `['*']` | All HTTP methods allowed for API. |\n| `allowed_origins` | `[FRONTEND_URL env var, default '*']` | Restrict to store domain in production. Wildcard for development. |\n| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |\n| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |\n| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |\n| `max_age` | `0` | No preflight caching (conservative default). |\n| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |\n\n---\n\n## 7. Webhook Signature Verification\n\n### 7.1 Inbound Payment Webhooks\n\n> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).\n\n---\n\n### 7.2 Platform Webhooks (Outbound)\n\nService: `App\\Services\\WebhookDispatcher`\n\nWhen the platform sends webhooks to external subscribers (third-party apps, integrations).\n\n```mermaid\nsequenceDiagram\n    participant App as Platform\n    participant Sub as Subscriber URL\n\n    App->>App: Serialize payload as JSON\n    App->>App: Generate Unix timestamp\n    App->>App: Concatenate: \"{timestamp}.{json_body}\"\n    App->>App: HMAC-SHA256(signing_secret, concatenated_message)\n    App->>Sub: POST to subscriber URL\n    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json\n    Sub-->>App: 200 OK (acknowledged)\n```\n\n**Requirements:**\n\n1. The payload is serialized as JSON.\n2. A Unix timestamp is generated (current time).\n3. The message to sign is: `\"{timestamp}.{json_body}\"` (timestamp, dot, JSON string).\n4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).\n5. The HTTP request to the subscriber includes three headers:\n   - `X-Platform-Signature`: the hex-encoded HMAC digest\n   - `X-Platform-Timestamp`: the Unix timestamp as a string\n   - `Content-Type`: `application/json`\n6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.\n\n---\n\n## 8. Audit Logging\n\n### 8.1 Logging Channel\n\nA dedicated `audit` channel must be added to `config/logging.php`.\n\n| Setting | Value |\n|---------|-------|\n| Driver | `daily` (file-based, daily rotation) |\n| Path | `storage/logs/audit.log` |\n| Level | `info` |\n| Retention | 90 days |\n\n---\n\n### 8.2 Log Entry Format\n\nAll audit log entries are structured JSON objects with the following schema:\n\n| Field | Type | Required | Description |\n|-------|------|:--------:|-------------|\n| `timestamp` | string (ISO-8601) | Y | When the event occurred |\n| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |\n| `user_id` | integer or null | N | ID of the admin user who triggered the event |\n| `store_id` | integer or null | N | ID of the store context |\n| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |\n| `resource_id` | integer or null | N | ID of the affected resource |\n| `ip` | string | Y | Client IP address |\n| `user_agent` | string | Y | Client user agent string |\n| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |\n| (additional keys) | varies | N | Event-specific extra data |\n\n---\n\n### 8.3 Events to Log\n\n#### Authentication Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `auth.login` | Successful admin login | user_id, ip, user_agent |\n| `auth.logout` | Admin logout | user_id |\n| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |\n| `customer.login` | Successful customer login | customer_id, store_id, ip |\n| `customer.registered` | New customer registration | customer_id, store_id |\n| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |\n\n#### Admin Resource Events\n\nFor each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:\n\n**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff\n\n| Event Pattern | Trigger | Extra Data |\n|---------------|---------|------------|\n| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |\n| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |\n| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |\n| `{resource}.restored` | Resource restored from soft-delete | resource_id |\n\n#### Security Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `api_token.created` | New API token issued | token_name, abilities list, user_id |\n| `api_token.revoked` | API token revoked | token_name, user_id |\n| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |\n| `store.settings_changed` | Store settings updated | changed fields with old/new values |\n\n---\n\n### 8.4 Audit Logger Service\n\nFile: `app/Services/AuditLogger.php`\n\nA service class that encapsulates audit log writing.\n\n**Method signature:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|:--------:|-------------|\n| `event` | string | Y | Event identifier |\n| `userId` | int or null | N | Acting user ID |\n| `storeId` | int or null | N | Store context ID |\n| `resourceType` | string or null | N | Affected resource type |\n| `resourceId` | int or null | N | Affected resource ID |\n| `extra` | associative array | N | Additional key-value pairs to include in the log entry |\n\n**Behavior:**\n\n1. Write to the `audit` logging channel at the `info` level.\n2. Automatically include `timestamp` (ISO-8601), `ip` (from request), and `user_agent` (from request) in every entry.\n3. Merge any `extra` data into the log context.\n\n---\n\n### 8.5 Integration with Laravel Events\n\nThe audit logger hooks into Laravel's built-in authentication events and custom model observers.\n\n#### Auth Event Listeners\n\nRegister listeners (in `AppServiceProvider` or `EventServiceProvider`) for these Laravel events:\n\n| Laravel Event | Audit Event ID | Data Captured |\n|--------------|----------------|---------------|\n| `Illuminate\\Auth\\Events\\Login` | `auth.login` | user_id from event |\n| `Illuminate\\Auth\\Events\\Failed` | `auth.failed_login` | email from credentials (never password) |\n| `Illuminate\\Auth\\Events\\Logout` | `auth.logout` | user_id from event |\n\n#### Model Observers\n\nFor each auditable resource model, create an observer (or use a shared trait) that logs `created`, `updated`, and `deleted` events. The `updated` observer should capture the changed fields (dirty attributes) with their original and new values.\n\n---\n\n## 9. Security Checklist Summary\n\n| # | Control | Implementation Approach | Priority |\n|---|---------|------------------------|----------|\n| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |\n| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |\n| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |\n| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |\n| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |\n| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |\n","structuredPatch":[{"oldStart":1038,"oldLines":7,"newStart":1038,"newLines":7,"lines":[" |----------|---------|------------|"," | `api_token.created` | New API token issued | token_name, abilities list, user_id |"," | `api_token.revoked` | API token revoked | token_name, user_id |","-| `webhook.signature_invalid` | Incoming webhook failed signature verification | source (`stripe` or `paypal`), ip |","+| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |"," | `store.settings_changed` | Store settings updated | changed fields with old/new values |"," "," ---"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"f1f76531-8c64-48a2-bb46-721bcf8842ff"}
{"parentUuid":"4c48e8a2-df0e-4aa7-962a-fa9978d57698","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BB3hfagoMz8CEHbKVjVrPV","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me mark Task #8 as complete and do a final sweep for any remaining Stripe payment references (excluding tax-related ones which are intentional)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":570,"cache_read_input_tokens":162779,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":570},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X8SzxQCttidSWdQGYA","type":"assistant","uuid":"597a3cd1-cd2c-4e09-b680-18708cc96d56","timestamp":"2026-02-11T15:29:56.971Z"}
{"parentUuid":"597a3cd1-cd2c-4e09-b680-18708cc96d56","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BB3hfagoMz8CEHbKVjVrPV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0139No8yVoRRGgt58tvbHeZ3","name":"TaskUpdate","input":{"taskId":"8","status":"completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":570,"cache_read_input_tokens":162779,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":570},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X8SzxQCttidSWdQGYA","type":"assistant","uuid":"63297626-cd2b-4539-9c9c-c4c8cb622b86","timestamp":"2026-02-11T15:29:57.630Z"}
{"parentUuid":"63297626-cd2b-4539-9c9c-c4c8cb622b86","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0139No8yVoRRGgt58tvbHeZ3","type":"tool_result","content":"Updated task #8 status"}]},"uuid":"1aebb53c-1f69-4ae3-92c7-7972663fc331","timestamp":"2026-02-11T15:29:57.632Z","toolUseResult":{"success":true,"taskId":"8","updatedFields":["status"],"statusChange":{"from":"in_progress","to":"completed"}},"sourceToolAssistantUUID":"63297626-cd2b-4539-9c9c-c4c8cb622b86"}
{"parentUuid":"1aebb53c-1f69-4ae3-92c7-7972663fc331","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01BB3hfagoMz8CEHbKVjVrPV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CZXVrLrDty9hVPqLpxA5L7","name":"Grep","input":{"pattern":"(?i)\\bstripe\\b","path":"/Users/fabianwesner/Herd/shop/specs","output_mode":"content","-n":true}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":570,"cache_read_input_tokens":162779,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":570},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X8SzxQCttidSWdQGYA","type":"assistant","uuid":"cd1d00f8-04bd-49de-a74a-87e8ad8aca55","timestamp":"2026-02-11T15:29:58.731Z"}
{"parentUuid":"cd1d00f8-04bd-49de-a74a-87e8ad8aca55","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_01CZXVrLrDty9hVPqLpxA5L7","toolUseID":"toolu_01CZXVrLrDty9hVPqLpxA5L7","timestamp":"2026-02-11T15:29:59.011Z","uuid":"1e367c1d-b7ed-43a5-b62a-25e816cf5d84"}
{"parentUuid":"cd1d00f8-04bd-49de-a74a-87e8ad8aca55","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CZXVrLrDty9hVPqLpxA5L7","type":"tool_result","content":"specs/06-AUTH-AND-SECURITY.md:937:> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).\nspecs/09-IMPLEMENTATION-ROADMAP.md: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. |\nspecs/claude-spec-check.md:261:- **Payment processing** (Stripe + PayPal stub, webhook handling)\nspecs/03-ADMIN-UI.md:1630:|  Provider: [Stripe Tax v]                                        |\nspecs/03-ADMIN-UI.md:1647:**Provider config:** Shown when mode = \"provider\". Provider select (Stripe Tax / None) and API key password input.\nspecs/05-BUSINESS-LOGIC.md:744:- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\nspecs/05-BUSINESS-LOGIC.md:796:### 8.4 Stripe Tax Provider (Stub)\nspecs/05-BUSINESS-LOGIC.md:800:- Sends line items and shipping address to Stripe Tax API.\nspecs/05-BUSINESS-LOGIC.md:806:| `\"block\"` | Block checkout progression if Stripe Tax API errors |\nspecs/05-BUSINESS-LOGIC.md: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.\nspecs/05-BUSINESS-LOGIC.md:1647:| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\nspecs/claude-spec-check-02.md:182:Spec 02 has inbound webhook endpoints (Stripe, PayPal) but no outbound webhook subscription CRUD API. The admin UI route `/admin/developers` (spec 02 line 124) mentions \"webhook management\" and spec 06 references the `manage-developers` gate covering webhooks. However, there are no REST API endpoints for creating, listing, updating, or deleting webhook subscriptions."}]},"uuid":"fe129b00-4c19-46d2-a638-e54bc25eb604","timestamp":"2026-02-11T15:29:59.010Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"specs/06-AUTH-AND-SECURITY.md:937:> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).\nspecs/09-IMPLEMENTATION-ROADMAP.md: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. |\nspecs/claude-spec-check.md:261:- **Payment processing** (Stripe + PayPal stub, webhook handling)\nspecs/03-ADMIN-UI.md:1630:|  Provider: [Stripe Tax v]                                        |\nspecs/03-ADMIN-UI.md:1647:**Provider config:** Shown when mode = \"provider\". Provider select (Stripe Tax / None) and API key password input.\nspecs/05-BUSINESS-LOGIC.md:744:- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\nspecs/05-BUSINESS-LOGIC.md:796:### 8.4 Stripe Tax Provider (Stub)\nspecs/05-BUSINESS-LOGIC.md:800:- Sends line items and shipping address to Stripe Tax API.\nspecs/05-BUSINESS-LOGIC.md:806:| `\"block\"` | Block checkout progression if Stripe Tax API errors |\nspecs/05-BUSINESS-LOGIC.md: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.\nspecs/05-BUSINESS-LOGIC.md:1647:| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\nspecs/claude-spec-check-02.md:182:Spec 02 has inbound webhook endpoints (Stripe, PayPal) but no outbound webhook subscription CRUD API. The admin UI route `/admin/developers` (spec 02 line 124) mentions \"webhook management\" and spec 06 references the `manage-developers` gate covering webhooks. However, there are no REST API endpoints for creating, listing, updating, or deleting webhook subscriptions.","numLines":12},"sourceToolAssistantUUID":"cd1d00f8-04bd-49de-a74a-87e8ad8aca55"}
{"parentUuid":"fe129b00-4c19-46d2-a638-e54bc25eb604","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CLQhFLxttvYKUueJgKpDKP","type":"message","role":"assistant","content":[{"type":"text","text":"The remaining \"Stripe\" references are all either:\n- **Tax-related** (Stripe Tax provider - a separate service for tax calculation, intentionally kept)\n- **In the \"PDF Default\" column** of the technology decisions table (showing what was in the original spec vs our decision)\n- **In `claude-spec-check*.md` files** (review documents, not core specs)\n- **In 06-AUTH-AND-SECURITY.md** (the note I added explaining why it's not applicable)\n\nThese are all correct and intentional. Let me verify all tasks are complete."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":859,"cache_read_input_tokens":163349,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":859},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X8tJUSdJK31CARpXGq","type":"assistant","uuid":"76613eaf-f350-4a8c-ae27-7e594ffd9f22","timestamp":"2026-02-11T15:30:05.139Z"}
{"parentUuid":"76613eaf-f350-4a8c-ae27-7e594ffd9f22","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01CLQhFLxttvYKUueJgKpDKP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01F6G3x2Xxwv6tTd1NWCBdRG","name":"TaskList","input":{}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":859,"cache_read_input_tokens":163349,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":859},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X8tJUSdJK31CARpXGq","type":"assistant","uuid":"62b35ccd-4006-48a4-ace2-4015217a8bd4","timestamp":"2026-02-11T15:30:05.287Z"}
{"parentUuid":"62b35ccd-4006-48a4-ace2-4015217a8bd4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01F6G3x2Xxwv6tTd1NWCBdRG","type":"tool_result","content":"No tasks found"}]},"uuid":"eb2ff991-775b-4dd7-9e04-cd657175b281","timestamp":"2026-02-11T15:30:05.289Z","toolUseResult":{"tasks":[]},"sourceToolAssistantUUID":"62b35ccd-4006-48a4-ace2-4015217a8bd4"}
{"parentUuid":"eb2ff991-775b-4dd7-9e04-cd657175b281","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GJnqD3wLm29RbRk2WSKzmg","type":"message","role":"assistant","content":[{"type":"text","text":"All tasks are completed. Here's a summary of all spec changes made:\n\n## Summary of Spec Updates\n\n### Files Updated (8 files across 9 specs):\n\n**01-DATABASE-SCHEMA.md** (done in prior session)\n- Added `payment_method` column to `checkouts` and `orders` tables\n- Changed `payments.provider` to CHECK IN ('mock'), added `payments.method` column\n- Updated ER diagram\n\n**02-API-ROUTES.md**\n- Replaced checkout flow diagram (removed Stripe/PayPal actors, added MockPSP)\n- Replaced Section 2.3 with single `POST /checkouts/{id}/pay` endpoint\n- Added `PUT /checkouts/{id}/payment-method` endpoint\n- Removed entire Section 4 (Stripe/PayPal webhook receivers)\n- Updated checkout state machine (removed webhook references, added `payment_selected` state)\n- Cleaned up webhook middleware, rate limits, and endpoint mappings\n\n**03-ADMIN-UI.md**\n- Added `confirmPayment()`, `markAsShipped()`, `markAsDelivered()` actions\n- Updated wireframe to show payment method instead of \"Provider: Stripe\"\n- Added \"Confirm Payment\" button for bank_transfer orders\n- Added Fulfillment Guard callout (warning when financial_status is not paid)\n- Added Fulfillment Cards with \"Mark as shipped\" / \"Mark as delivered\" buttons\n\n**04-STOREFRONT-UI.md**\n- Replaced Step 4 (Payment) with full payment method selection UI (radio buttons + forms)\n- Added credit card form, PayPal form, bank transfer form\n- Added bank transfer instructions on confirmation page\n- Updated flow diagram\n\n**05-BUSINESS-LOGIC.md** (done in prior session)\n- Replaced entire Section 10 with MockPaymentProvider architecture\n- Added payment-method-specific OMS workflows\n- Added fulfillment guard, auto-fulfillment for digital products\n- Added CancelUnpaidBankTransferOrders job\n\n**06-AUTH-AND-SECURITY.md**\n- Replaced Stripe/PayPal webhook verification sections with \"Not applicable\" note\n- Updated webhook routes middleware description\n- Updated security checklist\n\n**07-SEEDERS-AND-TEST-DATA.md**\n- Added `payment_method` to CheckoutFactory, OrderFactory\n- Updated PaymentFactory: provider \"mock\", added `method` field, updated states\n- Updated all 15 Acme Fashion orders with payment_method (11 credit_card, 2 paypal, 2 bank_transfer)\n- Changed Order #1005 from Stripe requires_action to bank_transfer pending\n- Changed Order #1015 to bank_transfer confirmed\n- Updated Acme Electronics orders\n\n**08-PLAYWRIGHT-E2E-PLAN.md**\n- Added 5 new checkout tests (PayPal, bank transfer, card decline, insufficient funds, payment method switching)\n- Added 4 new admin tests (confirm payment, fulfillment guard, mark shipped, mark delivered)\n- Updated test 9.1 to include card fields\n- Updated env variables (removed Stripe keys)\n- Total: 134 -> 143 tests\n\n**09-IMPLEMENTATION-ROADMAP.md**\n- Updated technology decisions: Stripe+PayPal -> Mock PSP\n- Replaced PaymentProvider/PaymentStatus enums with PaymentMethod/PaymentStatus\n- Replaced StripePaymentProvider/PaypalPaymentProvider with MockPaymentProvider\n- Added BankTransferConfirmation tests and CancelUnpaidBankTransferOrders job\n- Added fulfillment guard tests\n- Updated service registry"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":182,"cache_read_input_tokens":164208,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":182},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2X9MjySaaGTN4Kqq3NU","type":"assistant","uuid":"b9ae5610-216c-4516-9e59-2bd34d5d1f14","timestamp":"2026-02-11T15:30:29.444Z"}
{"parentUuid":"b9ae5610-216c-4516-9e59-2bd34d5d1f14","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"Stop","hookName":"Stop","command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"},"parentToolUseID":"c6e7c53d-e690-4257-9d6a-27c24c63ed4c","toolUseID":"c6e7c53d-e690-4257-9d6a-27c24c63ed4c","timestamp":"2026-02-11T15:30:29.600Z","uuid":"1b32cd5b-f1d0-4f0a-bb74-b3b79f563ab3"}
{"parentUuid":"1b32cd5b-f1d0-4f0a-bb74-b3b79f563ab3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-02-11T15:30:29.617Z","uuid":"030b617c-5277-4413-9c6d-ceba59a97b7d","toolUseID":"c6e7c53d-e690-4257-9d6a-27c24c63ed4c"}
{"parentUuid":"030b617c-5277-4413-9c6d-ceba59a97b7d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"turn_duration","durationMs":1403200,"timestamp":"2026-02-11T15:30:29.623Z","uuid":"1ff40097-dfab-43d1-885b-9304867b0983","isMeta":false}
{"type":"file-history-snapshot","messageId":"f9164427-c32c-42df-a8b7-996fe529c585","snapshot":{"messageId":"f9164427-c32c-42df-a8b7-996fe529c585","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v2","version":2,"backupTime":"2026-02-11T15:43:11.335Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v2","version":2,"backupTime":"2026-02-11T15:43:11.336Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v2","version":2,"backupTime":"2026-02-11T15:43:11.336Z"},"specs/03-ADMIN-UI.md":{"backupFileName":"a791bee027d52553@v2","version":2,"backupTime":"2026-02-11T15:43:11.337Z"},"specs/04-STOREFRONT-UI.md":{"backupFileName":"401411061d8a6ac1@v2","version":2,"backupTime":"2026-02-11T15:43:11.337Z"},"specs/07-SEEDERS-AND-TEST-DATA.md":{"backupFileName":"c11f09e3e5d3a009@v2","version":2,"backupTime":"2026-02-11T15:43:11.337Z"},"specs/08-PLAYWRIGHT-E2E-PLAN.md":{"backupFileName":"2744918423fe477c@v2","version":2,"backupTime":"2026-02-11T15:43:11.338Z"},"specs/09-IMPLEMENTATION-ROADMAP.md":{"backupFileName":"1f0335ca0ea0d4c4@v2","version":2,"backupTime":"2026-02-11T15:43:11.338Z"},"specs/06-AUTH-AND-SECURITY.md":{"backupFileName":"a579def47b6be477@v2","version":2,"backupTime":"2026-02-11T15:43:11.339Z"}},"timestamp":"2026-02-11T15:43:11.334Z"},"isSnapshotUpdate":false}
{"parentUuid":"1ff40097-dfab-43d1-885b-9304867b0983","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"Review and fix specs/codex-spec-check-04.md"},"uuid":"f9164427-c32c-42df-a8b7-996fe529c585","timestamp":"2026-02-11T15:43:11.332Z","thinkingMetadata":{"maxThinkingTokens":31999},"permissionMode":"acceptEdits"}
{"parentUuid":"f9164427-c32c-42df-a8b7-996fe529c585","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RbfdNnzYnNnzrU8QLXBLdQ","type":"message","role":"assistant","content":[{"type":"text","text":"\n\nLet me read that file first."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":18,"cache_read_input_tokens":165245,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":18},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Y9JhXiucnoqcCxS4Wr","type":"assistant","uuid":"2701fcab-4218-494a-b278-ccf7e4d11690","timestamp":"2026-02-11T15:43:14.557Z"}
{"parentUuid":"2701fcab-4218-494a-b278-ccf7e4d11690","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01RbfdNnzYnNnzrU8QLXBLdQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014DNTbobn9RqcHRvdd4gbnp","name":"Glob","input":{"pattern":"specs/*spec-check*"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":18,"cache_read_input_tokens":165245,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":18},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Y9JhXiucnoqcCxS4Wr","type":"assistant","uuid":"080d99d8-e315-4f6d-8826-6f6eeaf4dbcf","timestamp":"2026-02-11T15:43:14.862Z"}
{"parentUuid":"080d99d8-e315-4f6d-8826-6f6eeaf4dbcf","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Glob","command":"callback"},"parentToolUseID":"toolu_014DNTbobn9RqcHRvdd4gbnp","toolUseID":"toolu_014DNTbobn9RqcHRvdd4gbnp","timestamp":"2026-02-11T15:43:15.285Z","uuid":"680435ed-dd58-4d60-96cd-8394559c8628"}
{"parentUuid":"080d99d8-e315-4f6d-8826-6f6eeaf4dbcf","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014DNTbobn9RqcHRvdd4gbnp","type":"tool_result","content":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check.md\n/Users/fabianwesner/Herd/shop/specs/claude-spec-check.md\n/Users/fabianwesner/Herd/shop/specs/codex-spec-check-02.md\n/Users/fabianwesner/Herd/shop/specs/claude-spec-check-02.md\n/Users/fabianwesner/Herd/shop/specs/codex-spec-check-03.md\n/Users/fabianwesner/Herd/shop/specs/claude-spec-check-03.md\n/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md"}]},"uuid":"e919bf54-c05e-4f17-ba31-aa112bb4005c","timestamp":"2026-02-11T15:43:15.284Z","toolUseResult":{"filenames":["/Users/fabianwesner/Herd/shop/specs/codex-spec-check.md","/Users/fabianwesner/Herd/shop/specs/claude-spec-check.md","/Users/fabianwesner/Herd/shop/specs/codex-spec-check-02.md","/Users/fabianwesner/Herd/shop/specs/claude-spec-check-02.md","/Users/fabianwesner/Herd/shop/specs/codex-spec-check-03.md","/Users/fabianwesner/Herd/shop/specs/claude-spec-check-03.md","/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md"],"durationMs":420,"numFiles":7,"truncated":false},"sourceToolAssistantUUID":"080d99d8-e315-4f6d-8826-6f6eeaf4dbcf"}
{"parentUuid":"e919bf54-c05e-4f17-ba31-aa112bb4005c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01JhVwKTwrx97j5eKbXmpQRX","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ThrDfLGQEZ7qg7sma4YCQ6","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":268,"cache_read_input_tokens":165263,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":268},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2Y9aqe7qcoVSmUwe4hR","type":"assistant","uuid":"9701c9e9-0378-4dbd-a7ee-d300e6dc333c","timestamp":"2026-02-11T15:43:18.307Z"}
{"parentUuid":"cd0f027d-253d-41f8-a31e-b26c184f043c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01ThrDfLGQEZ7qg7sma4YCQ6","toolUseID":"toolu_01ThrDfLGQEZ7qg7sma4YCQ6","timestamp":"2026-02-11T15:43:18.310Z","uuid":"381c78a8-9582-4042-a607-140cc8e8d3e8"}
{"parentUuid":null,"logicalParentUuid":"cd0f027d-253d-41f8-a31e-b26c184f043c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"compact_boundary","content":"Conversation compacted","isMeta":false,"timestamp":"2026-02-11T15:44:50.932Z","uuid":"7d9e6c9e-9294-426a-be1f-59a43c4aab21","level":"info","compactMetadata":{"trigger":"auto","preTokens":167810}}
{"parentUuid":"7d9e6c9e-9294-426a-be1f-59a43c4aab21","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nAnalysis:\nLet me chronologically analyze the conversation:\n\n1. **Context from previous conversation (summarized)**: The user has been building a Shopify-clone e-commerce platform with Laravel 12, Livewire v4, Flux UI Free, Tailwind CSS v4, and SQLite. Through multiple rounds of discussion, they decided on:\n   - Onsite checkout with mock PSP (no real Stripe/PayPal)\n   - 3 payment methods: Credit Card, PayPal, Bank Transfer\n   - Magic card numbers for testing\n   - Payment-method-specific OMS workflows\n   - Admin button-driven events (shipped, delivered)\n   - Fulfillment guard\n   - Auto-fulfillment for digital products\n   - The user explicitly asked: \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\"\n\n2. **First message in current conversation**: Continuation prompt asking to continue the previous work. The assistant had already completed edits to 01-DATABASE-SCHEMA.md and 05-BUSINESS-LOGIC.md (in the prior session), and partially completed 02-API-ROUTES.md.\n\n3. **The assistant's work in this session**:\n   - Read multiple spec files to understand remaining changes needed\n   - Created tasks #4-#8 for tracking remaining work\n   - Completed Task #3 (02-API-ROUTES.md): Fixed payment response examples, removed webhook endpoints section, added PUT payment-method endpoint, updated checkout state machine, cleaned webhook references\n   - Completed Task #4 (03-ADMIN-UI.md): Added confirm payment, fulfillment guard, mark shipped/delivered\n   - Completed Task #5 (04-STOREFRONT-UI.md): Replaced Step 4 with payment method selection UI\n   - Completed Task #6 (07-SEEDERS-AND-TEST-DATA.md): Updated factories, all 15+ orders with payment_method\n   - Completed Task #7 (08-PLAYWRIGHT-E2E-PLAN.md): Added 9 new tests, updated existing tests\n   - Completed Task #8 (09-IMPLEMENTATION-ROADMAP.md): Updated technology decisions, services, tests\n   - Also fixed remaining Stripe/PayPal references in 06-AUTH-AND-SECURITY.md\n\n4. **Second user message**: \"Review and fix specs/codex-spec-check-04.md\"\n   - The assistant read the file which contains a consistency check report with 10 findings\n   - The findings identified inconsistencies across specs introduced during the Mock PSP migration\n   - The assistant had just read the file when the summarization request came\n\n5. **Third user message**: The current summarization request\n\nLet me identify all the specific findings from codex-spec-check-04.md that need to be addressed:\n\n1. **Critical**: `payment_selected` vs `payment_pending` inconsistency across specs\n2. **Critical**: Payment status enum inconsistency (schema still has `requires_action`, `authorized`)\n3. **High**: Roadmap test plan missing new payment endpoints\n4. **High**: `02` pay endpoint error text references `payment_pending`\n5. **High**: Roadmap scheduled job inventory missing CancelUnpaidBankTransferOrders\n6. **Medium**: Orphan `/payments*` endpoint mapping in `02`\n7. **Medium**: Settings tab scope differs across specs\n8. **Medium**: Browser test totals inconsistent (143 top vs 134 bottom in `08`)\n9. **Low**: Webhook direction wording drift in security spec\n10. **Low**: PayPal copy implies redirect despite mock in-process flow\n\nThe user's last request was to \"Review and fix specs/codex-spec-check-04.md\" - meaning review the findings and fix the inconsistencies identified in the report. The assistant had just read the file but hadn't started making any fixes yet.\n\nSummary:\n1. Primary Request and Intent:\n   The user is building a Shopify-clone e-commerce platform with Laravel 12, Livewire v4, Flux UI Free, Tailwind CSS v4, and SQLite. Through extensive prior discussion, they decided on onsite checkout with a Mock PSP (no real Stripe/PayPal), 3 payment methods (Credit Card, PayPal, Bank Transfer), magic card numbers for testing, payment-method-specific OMS workflows, admin button-driven fulfillment events, a fulfillment guard, and auto-fulfillment for digital products. They explicitly requested: \"Based on our discussion, adjust the specs. OMS needs to be in place (not the simple solution)\" and \"Events (like 'shipped') are triggered by admin buttons.\"\n\n   In the current conversation, the user first asked to continue from a prior session that ran out of context (continuing spec file updates). After the assistant completed all spec updates, the user's most recent request was: **\"Review and fix specs/codex-spec-check-04.md\"** - meaning review the 10 consistency findings in that report and fix the identified inconsistencies across all spec files.\n\n2. Key Technical Concepts:\n   - **Laravel 12** with Livewire v4, Flux UI Free, Tailwind CSS v4, SQLite (WAL mode)\n   - **Mock PSP (MockPaymentProvider)**: Replaces Stripe/PayPal - no external API calls, no webhooks, no redirects\n   - **3 Payment Methods**: `credit_card`, `paypal`, `bank_transfer` via `App\\Enums\\PaymentMethod`\n   - **Payment-method-specific OMS workflows**: Card/PayPal = instant capture (paid immediately); Bank Transfer = deferred (pending until admin confirms)\n   - **Fulfillment Guard**: Prevents creating fulfillments unless `financial_status` is `paid` or `partially_refunded`\n   - **Admin button-driven events**: \"Mark as shipped\", \"Mark as delivered\", \"Confirm Payment\" are admin UI buttons\n   - **Auto-fulfillment for digital products**: When `requires_shipping = false` and payment confirmed\n   - **CancelUnpaidBankTransferOrders**: Scheduled job to cancel unpaid bank transfer orders after configurable days (default 7)\n   - **Magic card numbers**: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n   - **PaymentStatus enum**: `Pending`, `Captured`, `Failed`, `Refunded` (simplified from Stripe-era values)\n   - **Checkout state machine**: Added `payment_selected` state between `shipping_selected` and `completed`\n   - **Multi-tenant architecture**: Organizations -> Stores, store-scoped data\n\n3. Files and Code Sections:\n\n   - **specs/01-DATABASE-SCHEMA.md** (edited in prior session, referenced in current findings)\n     - Added `payment_method` column to `checkouts` and `orders` tables\n     - Changed `payments.provider` CHECK to ('mock'), added `payments.method` column\n     - **Finding #1**: Still has `payment_pending` in checkout status CHECK constraint (line ~587) - needs to be changed to `payment_selected`\n     - **Finding #2**: `payments.status` still has legacy values `requires_action`, `authorized` (line ~829) - needs alignment to `pending|captured|failed|refunded`\n\n   - **specs/02-API-ROUTES.md** (edited in this session)\n     - Replaced checkout flow diagram with MockPSP version\n     - Replaced Section 2.3 with single `POST /checkouts/{id}/pay` endpoint\n     - Added `PUT /checkouts/{id}/payment-method` endpoint (new, between shipping-method and apply-discount)\n     - Removed entire Section 4 (Stripe/PayPal webhook receivers), replaced with note about Mock PSP\n     - Updated checkout state machine to use `payment_selected` state\n     - Cleaned up webhook middleware, rate limits, endpoint mappings\n     - **Finding #4**: Pay endpoint error text at ~line 936 still references `payment_pending`\n     - **Finding #6**: Orphan `/payments*` endpoint mapping still at ~line 3121\n\n   - **specs/03-ADMIN-UI.md** (edited in this session)\n     - Added `confirmPayment()`, `markAsShipped(int $fulfillmentId)`, `markAsDelivered(int $fulfillmentId)` actions\n     - Updated wireframe: \"Method: Credit Card [Paid]\" instead of \"Provider: Stripe [Paid]\"\n     - Added \"Confirm Payment\" button (visible when payment_method=bank_transfer AND financial_status=pending)\n     - Added Fulfillment Guard callout (Flux `callout` variant: warning)\n     - Added Fulfillment Cards with \"Mark as shipped\" / \"Mark as delivered\" buttons\n\n   - **specs/04-STOREFRONT-UI.md** (edited in this session)\n     - Replaced Step 4 heading to \"Payment Method & Pay\"\n     - Added full payment method selection UI: radio buttons (Credit Card / PayPal / Bank Transfer)\n     - Credit card form: card number, cardholder name, expiry (MM/YY), CVC\n     - PayPal form: informational text + \"Pay with PayPal\" button\n     - Bank transfer form: informational text + \"Place order\" button\n     - Added bank transfer instructions on confirmation page (IBAN, BIC, amount, reference)\n     - Updated flow diagram: removed \"Stripe/PayPal Redirect\" node\n     - **Finding #10**: PayPal form copy says \"You will be redirected to complete your PayPal payment.\" at line ~1087 - contradicts mock in-process flow\n\n   - **specs/05-BUSINESS-LOGIC.md** (edited in prior session, referenced in findings)\n     - Complete replacement of Section 10 with MockPaymentProvider architecture\n     - Added payment-method-specific OMS workflows, fulfillment guard, auto-fulfillment\n     - **Finding #1**: Still models `payment_pending` transitions at lines ~415, ~487\n\n   - **specs/06-AUTH-AND-SECURITY.md** (edited in this session)\n     - Replaced Section 7.1/7.2 (Stripe/PayPal webhook verification) with \"Not applicable\" note\n     - Updated webhook routes middleware description\n     - Updated security checklist item #14\n     - Updated audit log entry for webhook.signature_invalid\n     - **Finding #9**: Rate limiter table at line ~744 still says webhooks for incoming endpoints\n\n   - **specs/07-SEEDERS-AND-TEST-DATA.md** (edited in this session)\n     - CheckoutFactory: Added `payment_method` field (null default), added states `withCreditCard`, `withPaypal`, `withBankTransfer`\n     - OrderFactory: Added `payment_method` field (default \"credit_card\"), added states `pendingBankTransfer`, `creditCard`, `paypal`, `bankTransfer`\n     - PaymentFactory: Changed provider from \"stripe\" to \"mock\", added `method` field (default \"credit_card\"), changed provider_payment_id prefix to \"mock_\", replaced states (removed `requiresAction`/`authorized`, added `pending`/`creditCard`/`paypal`/`bankTransfer`)\n     - RefundFactory: Changed provider_refund_id prefix from \"re_\" to \"mock_re_\"\n     - All 15 Acme Fashion orders updated: 11 credit_card, 2 paypal (#1007, #1010), 2 bank_transfer (#1005 pending, #1015 confirmed)\n     - Order #1005 changed from Stripe requires_action to bank_transfer pending with test purpose note\n     - Order #1014 fulfillment updated to note \"auto-fulfilled digital product\"\n     - Order summary table updated with Payment Method column\n     - Acme Electronics orders updated with payment_method column\n\n   - **specs/08-PLAYWRIGHT-E2E-PLAN.md** (edited in this session)\n     - Suite 4 test count: 7 -> 11 (added tests 4.8-4.11)\n     - Suite 9 test count: 8 -> 13 (added tests 9.9-9.13)\n     - Total: 134 -> 143\n     - Updated checkout flow diagram with payment method selection paths\n     - Updated checkout step detail table\n     - Updated Test 9.1 to include card field entry (card number, cardholder, expiry, CVC)\n     - New Test 4.8: Can confirm bank transfer payment\n     - New Test 4.9: Shows fulfillment guard for unpaid order\n     - New Test 4.10: Can mark fulfillment as shipped\n     - New Test 4.11: Can mark fulfillment as delivered\n     - New Test 9.9: Completes checkout with PayPal\n     - New Test 9.10: Completes checkout with bank transfer\n     - New Test 9.11: Shows error for declined credit card (magic number 4000000000000002)\n     - New Test 9.12: Shows error for insufficient funds (magic number 4000000000009995)\n     - New Test 9.13: Switches between payment method forms\n     - Updated env variables: removed STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET, added PAYMENT_PROVIDER=mock\n     - Updated payment testing guidance note\n     - **Finding #8**: Bottom summary at ~line 3140 still says 134 tests (should be 143)\n\n   - **specs/09-IMPLEMENTATION-ROADMAP.md** (edited in this session)\n     - Technology decisions: \"Stripe + PayPal\" -> \"Mock PSP (in-process)\"\n     - PaymentProvider/PaymentStatus enums replaced with PaymentMethod/PaymentStatus\n     - Step 5.3 entirely replaced: StripePaymentProvider/PaypalPaymentProvider -> MockPaymentProvider\n     - Added Step 5.3b: Admin Payment Confirmation (CancelUnpaidBankTransferOrders job + confirm payment action)\n     - Step 5.6 FulfillmentService updated: added fulfillment guard, markAsShipped/markAsDelivered methods\n     - Test files updated: StripeWebhookTest.php -> MockPaymentProviderTest.php + BankTransferConfirmationTest.php\n     - Service registry: replaced StripePaymentProvider/PaypalPaymentProvider with MockPaymentProvider\n     - FulfillmentTest.php: added 4 new test cases (fulfillment guard tests + auto-fulfillment digital)\n     - **Finding #3**: Storefront API test matrix (~lines 1652-1656) missing /payment-method and /pay endpoints\n     - **Finding #5**: Consolidated routes/console.php schedule list (~line 1950) missing CancelUnpaidBankTransferOrders\n     - **Finding #8**: Effort estimate (~line 2014) still uses 134 browser tests\n\n   - **specs/codex-spec-check-04.md** (read, not yet fixed)\n     - Consistency check report with 10 findings across all specs\n     - Identifies remaining inconsistencies after Mock PSP migration\n     - Readiness assessment: \"Medium\"\n\n4. Errors and fixes:\n   - No code errors were encountered during editing in this session\n   - File-too-large errors from prior session when reading 02-API-ROUTES.md were handled by using offset/limit parameters\n   - The codex-spec-check-04.md report itself identifies 10 inconsistencies that were introduced during the spec migration process - these are the \"errors\" that need to be fixed\n\n5. Problem Solving:\n   - Successfully migrated all 9 spec files from Stripe/PayPal to Mock PSP architecture\n   - Systematically read all affected sections before making changes to ensure consistency\n   - Used task tracking to manage the multi-file update process\n   - Final grep sweep confirmed remaining Stripe references are either tax-related (intentional) or in review documents\n   - The codex-spec-check-04.md report reveals that despite the systematic approach, several cross-file inconsistencies remain (checkout state naming, payment status enum values, test inventories, stale references)\n\n6. All user messages:\n   - \"Please continue the conversation from where we left off without asking the user any further questions. Continue with the last task that you were asked to work on.\" (continuation prompt)\n   - \"Review and fix specs/codex-spec-check-04.md\" (most recent explicit request)\n   - Current summarization request\n\n7. Pending Tasks:\n   - **Fix all 10 findings from codex-spec-check-04.md** - this is the user's most recent explicit request. The file was read but no fixes have been applied yet. The specific fixes needed are:\n     1. **Critical**: Align checkout state to `payment_selected` in specs 01 (schema CHECK constraint), 05 (business logic transitions), 09 (roadmap tests)\n     2. **Critical**: Align `payments.status` CHECK in spec 01 to `pending|captured|failed|refunded` (remove `requires_action`, `authorized`)\n     3. **High**: Add `/payment-method` and `/pay` endpoint tests to spec 09 storefront API test matrix\n     4. **High**: Fix spec 02 pay endpoint 409 error text that still references `payment_pending`\n     5. **High**: Add `CancelUnpaidBankTransferOrders` to consolidated `routes/console.php` schedule in spec 09\n     6. **Medium**: Remove orphan `/payments*` endpoint mapping row in spec 02\n     7. **Medium**: Reconcile settings tab scope across specs 02, 03, 08\n     8. **Medium**: Fix browser test total at bottom of spec 08 (134 -> 143) and in spec 09 effort estimate\n     9. **Low**: Fix webhook wording drift in spec 06 rate limiter table\n     10. **Low**: Fix PayPal copy in spec 04 that says \"You will be redirected\"\n\n8. Current Work:\n   The assistant had just read `specs/codex-spec-check-04.md` in response to the user's request \"Review and fix specs/codex-spec-check-04.md\". The file contains 10 findings (2 critical, 3 high, 3 medium, 2 low) identifying cross-spec inconsistencies remaining after the Mock PSP migration. The assistant read the full file but had not yet started making any fixes when the summarization was requested.\n\n9. Optional Next Step:\n   Address all 10 findings from `specs/codex-spec-check-04.md` in order of severity, starting with the two critical issues:\n   \n   **Finding #1 (Critical)**: Change `payment_pending` to `payment_selected` in:\n   - `specs/01-DATABASE-SCHEMA.md` (~line 587) - checkout status CHECK constraint\n   - `specs/05-BUSINESS-LOGIC.md` (~lines 415, 487) - checkout state transitions\n   - `specs/09-IMPLEMENTATION-ROADMAP.md` (~lines 575, 1333) - tests/references\n   \n   **Finding #2 (Critical)**: Update `payments.status` CHECK in `specs/01-DATABASE-SCHEMA.md` (~line 829) from `requires_action|authorized|captured|failed|refunded` to `pending|captured|failed|refunded`\n   \n   Then proceed through findings #3-#10 as listed in the report. After all fixes are applied, update the codex-spec-check-04.md file itself to reflect the resolved status.\n   \n   The user's exact request was: **\"Review and fix specs/codex-spec-check-04.md\"**\n\nIf you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/fabianwesner/.claude/projects/-Users-fabianwesner-Herd-shop/17a2a457-7a36-4d51-927a-aa26d07a423b.jsonl\nPlease continue the conversation from where we left off without asking the user any further questions. Continue with the last task that you were asked to work on."},"isVisibleInTranscriptOnly":true,"isCompactSummary":true,"uuid":"5c966bfe-c8f2-4487-9c29-8fa2d161c6ea","timestamp":"2026-02-11T15:44:50.932Z"}
{"parentUuid":"5c966bfe-c8f2-4487-9c29-8fa2d161c6ea","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"text","text":"\n\n"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"ca6fc96e-45ec-4804-b080-11167401c67f","timestamp":"2026-02-11T15:44:53.717Z"}
{"parentUuid":"ca6fc96e-45ec-4804-b080-11167401c67f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to continue fixing the 10 findings from `specs/codex-spec-check-04.md`. I need to read the relevant sections of each spec file and make the fixes. Let me start by reading the specific lines mentioned in the findings to understand exactly what needs to change.\n\nLet me read the relevant sections in parallel.","signature":"Eu4DCkYICxgCKkBhpNk9fNtUA9PBLd0lvkMQcw4d2lx3tjt3hbFpS9wOXDSVjSbCvjVqBDAuS79OjtRN3Yo9+sbhZGVEZUj7jhYqEgz7UtuCYXIUqGCgoxQaDAMlg0A8Qqb5am2pNyIwto834RFz+LUtw5svcC5wqAkoWTjouLgt1f6uCyvhSvpaphaAc8lAy35UIxdETX54KtUCJS3QUw1p3sLf6cJRJMbYB/nTMxv2hRUDOcV7NZWFMruSe1vmKUbrdK+CwtxfAZesLc0Kw3WEOctZSFVt8CtE6RDwjSC2b8ubrAebZsZZ8cEzQeeXjcyrVso6xqOtzoY45tE+z+779G133BmBxBrc3TbN7feccBBF0aVotSzxSG5F+Q2279RkoaKHg4ZSk+EbU2XHcpj2QlM0HW1Gkd4rpNRZRy39h5G2cIuj81G1ra/q5ByEeZSz75VyleH6CA7hmunnYo7S1SJNUnfu6X8TlKnt9ierl0BUndqL9ODUH5tGl9P0ztp9ziJRcFPuVNUQDoZLbnvwFLxLEhKp1SvjEOpm9DSoQKBhQJVo4jq1mBsiUARySld67XSThcY9TP1rOfghi6lnE0A2DvkJ0h4r0WqnrvbBvrUWI4zpiwlHjvZvt5MJVQ1wpiUDQXyxJ+HgC0UuiyAYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"2320fa8a-845b-4517-aedc-9395822373ed","timestamp":"2026-02-11T15:44:54.647Z"}
{"parentUuid":"2320fa8a-845b-4517-aedc-9395822373ed","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"text","text":"I'll now fix all 10 findings from the consistency check report. Let me start by reading the relevant sections across all spec files."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"b619c439-f7d4-417d-aa59-52765931f658","timestamp":"2026-02-11T15:44:55.321Z"}
{"parentUuid":"b619c439-f7d4-417d-aa59-52765931f658","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Qo7asu1sLYbySv9XMiLhkc","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","offset":580,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"9101776c-8fb4-4ec7-9a34-769c210f97c6","timestamp":"2026-02-11T15:44:56.230Z"}
{"parentUuid":"9101776c-8fb4-4ec7-9a34-769c210f97c6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01Qo7asu1sLYbySv9XMiLhkc","toolUseID":"toolu_01Qo7asu1sLYbySv9XMiLhkc","timestamp":"2026-02-11T15:44:56.234Z","uuid":"4d481b0a-9b8b-4d6d-bb78-f0d78594e0c4"}
{"parentUuid":"9101776c-8fb4-4ec7-9a34-769c210f97c6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Qo7asu1sLYbySv9XMiLhkc","type":"tool_result","content":"   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_pending', '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 |"}]},"uuid":"fe07d941-e6b1-4234-963c-634a32c331f5","timestamp":"2026-02-11T15:44:56.233Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","content":"\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |","numLines":15,"startLine":580,"totalLines":1684}},"sourceToolAssistantUUID":"9101776c-8fb4-4ec7-9a34-769c210f97c6"}
{"parentUuid":"fe07d941-e6b1-4234-963c-634a32c331f5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018wiqeoCBWNjwDV4vQALZs5","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","offset":820,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"879b4a87-e802-4566-900a-d9f283a44b13","timestamp":"2026-02-11T15:44:56.924Z"}
{"parentUuid":"879b4a87-e802-4566-900a-d9f283a44b13","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_018wiqeoCBWNjwDV4vQALZs5","toolUseID":"toolu_018wiqeoCBWNjwDV4vQALZs5","timestamp":"2026-02-11T15:44:56.929Z","uuid":"2b3c5e94-6b25-4fd2-9c94-db7e89b42a5d"}
{"parentUuid":"879b4a87-e802-4566-900a-d9f283a44b13","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018wiqeoCBWNjwDV4vQALZs5","type":"tool_result","content":"   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 | 'requires_action' | CHECK IN ('requires_action', 'authorized', '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→"}]},"uuid":"e08e181c-f384-4b99-bca3-4c6a8b4c8fef","timestamp":"2026-02-11T15:44:56.928Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","content":"Payment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n","numLines":15,"startLine":820,"totalLines":1684}},"sourceToolAssistantUUID":"879b4a87-e802-4566-900a-d9f283a44b13"}
{"parentUuid":"e08e181c-f384-4b99-bca3-4c6a8b4c8fef","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NGvdvRrvuFDijBKi7JYEGT","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":408,"limit":20}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"6eea46f6-7f3f-4a42-aaac-b0854fe4f214","timestamp":"2026-02-11T15:44:57.697Z"}
{"parentUuid":"6eea46f6-7f3f-4a42-aaac-b0854fe4f214","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01NGvdvRrvuFDijBKi7JYEGT","toolUseID":"toolu_01NGvdvRrvuFDijBKi7JYEGT","timestamp":"2026-02-11T15:44:57.701Z","uuid":"e50f1e21-9431-43e6-8a37-93db82183b7d"}
{"parentUuid":"6eea46f6-7f3f-4a42-aaac-b0854fe4f214","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NGvdvRrvuFDijBKi7JYEGT","type":"tool_result","content":"   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_pending`, `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_pending : Payment initiated\n   423→    payment_pending --> completed : Payment confirmed\n   424→    payment_pending --> expired : Timeout (24h)\n   425→\n   426→    started --> expired : Timeout (24h)\n   427→    addressed --> expired : Timeout (24h)"}]},"uuid":"c7d05f30-0af6-4ed8-800c-d852d86a2440","timestamp":"2026-02-11T15:44:57.700Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)","numLines":20,"startLine":408,"totalLines":1710}},"sourceToolAssistantUUID":"6eea46f6-7f3f-4a42-aaac-b0854fe4f214"}
{"parentUuid":"c7d05f30-0af6-4ed8-800c-d852d86a2440","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017nkd6xeA3r9KG2STs9zECW","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":480,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"037ffc1a-9bff-4bc8-973c-a3f1a957b3e1","timestamp":"2026-02-11T15:44:58.426Z"}
{"parentUuid":"037ffc1a-9bff-4bc8-973c-a3f1a957b3e1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_017nkd6xeA3r9KG2STs9zECW","toolUseID":"toolu_017nkd6xeA3r9KG2STs9zECW","timestamp":"2026-02-11T15:44:58.430Z","uuid":"b7c058f4-cda5-4c71-ba21-0f47f3d9d7cc"}
{"parentUuid":"037ffc1a-9bff-4bc8-973c-a3f1a957b3e1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017nkd6xeA3r9KG2STs9zECW","type":"tool_result","content":"   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_pending\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`."}]},"uuid":"e484c24c-ca7b-4320-b42b-7c4f5b411c53","timestamp":"2026-02-11T15:44:58.430Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.","numLines":15,"startLine":480,"totalLines":1710}},"sourceToolAssistantUUID":"037ffc1a-9bff-4bc8-973c-a3f1a957b3e1"}
{"parentUuid":"e484c24c-ca7b-4320-b42b-7c4f5b411c53","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016gxCuUsNqKU8EHMxRXdyhe","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":568,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"42c46c6b-05e0-4e50-a292-261691b072d7","timestamp":"2026-02-11T15:44:59.159Z"}
{"parentUuid":"42c46c6b-05e0-4e50-a292-261691b072d7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_016gxCuUsNqKU8EHMxRXdyhe","toolUseID":"toolu_016gxCuUsNqKU8EHMxRXdyhe","timestamp":"2026-02-11T15:44:59.162Z","uuid":"168d000f-83d9-425c-b6d4-b2c5dac5269e"}
{"parentUuid":"42c46c6b-05e0-4e50-a292-261691b072d7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016gxCuUsNqKU8EHMxRXdyhe","type":"tool_result","content":"   568→\n   569→**File to create:** `App\\Services\\CheckoutService`\n   570→\n   571→| Transition | Method | Requirements |\n   572→|------------|--------|-------------|\n   573→| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n   574→| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n   575→| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n   576→| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n   577→| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n   578→\n   579→**Files to create:**\n   580→\n   581→| Type | Name | Purpose |\n   582→|------|------|---------|"}]},"uuid":"65583222-a5fa-4c16-8666-1d9e5df9839d","timestamp":"2026-02-11T15:44:59.161Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|","numLines":15,"startLine":568,"totalLines":2063}},"sourceToolAssistantUUID":"42c46c6b-05e0-4e50-a292-261691b072d7"}
{"parentUuid":"65583222-a5fa-4c16-8666-1d9e5df9839d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016cCs7EDXc4AkCQqsqUvYUk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XyDscPe76ME4JdCeWhnRQa","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1326,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":29419,"cache_read_input_tokens":20686,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":29419},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YGddWXg9YSds2v8k1w","type":"assistant","uuid":"172e5037-44d6-44e4-af4b-4b7b168ff569","timestamp":"2026-02-11T15:44:59.880Z"}
{"parentUuid":"172e5037-44d6-44e4-af4b-4b7b168ff569","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01XyDscPe76ME4JdCeWhnRQa","toolUseID":"toolu_01XyDscPe76ME4JdCeWhnRQa","timestamp":"2026-02-11T15:44:59.882Z","uuid":"2778cc17-9137-404d-98de-e02aa934df8b"}
{"parentUuid":"172e5037-44d6-44e4-af4b-4b7b168ff569","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XyDscPe76ME4JdCeWhnRQa","type":"tool_result","content":"  1326→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1327→|-----------|----------------------|----------------|----------|\n  1328→| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n  1329→| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n  1330→| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n  1331→| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n  1332→| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n  1333→| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n  1334→| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n  1335→| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n  1336→| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n  1337→\n  1338→---\n  1339→\n  1340→#### tests/Feature/Checkout/PricingIntegrationTest.php"}]},"uuid":"4869863d-7524-4442-8558-2060b833f72d","timestamp":"2026-02-11T15:44:59.882Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php","numLines":15,"startLine":1326,"totalLines":2063}},"sourceToolAssistantUUID":"172e5037-44d6-44e4-af4b-4b7b168ff569"}
{"parentUuid":"4869863d-7524-4442-8558-2060b833f72d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me read the remaining sections that need fixes. I need to read:\n- specs/02-API-ROUTES.md around line 936 (Finding #4: pay endpoint error text)\n- specs/02-API-ROUTES.md around line 3121 (Finding #6: orphan /payments* mapping)\n- specs/02-API-ROUTES.md around line 92 (Finding #7: settings tabs)\n- specs/03-ADMIN-UI.md around line 1423 (Finding #7: settings tabs)\n- specs/08-PLAYWRIGHT-E2E-PLAN.md around line 1041 (Finding #7: settings tabs)\n\n\n- specs/08-PLAYWRIGHT-E2E-PLAN.md around line 3140 (Finding #8: bottom summary)\n- specs/09-IMPLEMENTATION-ROADMAP.md around line 1650-1660 (Finding #3: storefront API tests)\n- specs/09-IMPLEMENTATION-ROADMAP.md around line 1950 (Finding #5: schedule list)\n- specs/09-IMPLEMENTATION-ROADMAP.md around line 2014 (Finding #8: effort estimate)\n- specs/06-AUTH-AND-SECURITY.md around line 617 (Finding #9: webhook direction)\n- specs/06-AUTH-AND-SECURITY.md around line 744 (Finding #9: rate limiter table)\n- specs/04-STOREFRONT-UI.md around line 1087 (Finding #10: PayPal redirect copy)\n- specs/09-IMPLEMENTATION-ROADMAP.md around line 650 (Finding #2: PaymentStatus)\n- specs/09-IMPLEMENTATION-ROADMAP.md around line 686 (Finding #5: bank transfer cancellation job)\n\nI'm cross-referencing multiple spec documents to track down inconsistencies and gaps across the authentication, payment processing, and implementation roadmap sections. Looking at the rate limiting configuration, PayPal integration messaging, payment status enums, and background job definitions to see where the details diverge or need alignment.","signature":"EoMLCkYICxgCKkChZjglfEzOtZHP1+YX1H1BiSM7iUfr0QA0XcqnT6pwOm+0MVWWsDo+sMuDl8sZivXdSsfUMRgCK8yozoXQqVpFEgzfd9q2SU09SabDy2AaDLGpY5Y3uNlwWCO0zCIw4orgOu7p8WkZfjoshsmxGDONVcZiPI2D+svX/563+wvUZLodA0vGKsxma9DZd/QtKuoJsUw66prMYdrmjXIXwz8K3L4ou7aUrZmsqnG2c6wXfR8rlRxnpgUR4AUseb1Jg8hmjMHpEixebzzlPqjm/gJKr3hqt9aqn47Tj1yoKBFoYLozb3a7cKpMLSRfQY/S0IqZr4UoCbeTAmMPmk5di2ncAaVqn4lm3k+VTrD0qsVSs6gq/qTRihzStkaCO+4uJLJr3X2bQWP6yeEKYkOOX4ghLgZYnDvpnXr30y0ax88+fnLhkXmH1QrSDhO/T8tpfh0GXbWFahnBflaFHt7oQGYFqbXSG2X6s0+EAgwaluI6f7SgNk2Cy+Ia1l5ylVX6FnKGuRo3g37tWt6rZnOMBj+VMjEkWTehmjKgAWM1LITTCokULhTeJl/qX6f0/qnrHgmjtE0VRIAw6dSWAQ8PcnwkF68KHTAbZP2Gr6hVJsBPx1d4E0BkBDKtms1eCvKvXZ0xiZTUJm7eh6hOudZRxlacuX4LjKTX0q0BbdjUS5HTzzGMe+9KDjIHV822V0MMoQX7W9sFoZjGFV7/iJQLG0qDv8vFY81NLvZmKWy5CJXUoIgjnu04/YzjlcN00NYL/HyOWAiL4P3DZXhUn6XpRgPdOeTsUa7AKholoYJuMfWBS4hd/IQykBHIsIQ+Lo2oKNXTpUJrOC1qRULd76cLr4LjarS8bXRMEE1gQl9hMeh//nPOw2AMHjIUZAfpX1steBAZ87Pkw708nJp/YxuHfQEaG3HA42ylszhIPl9Uul3bqRTcL98WkmjmxXMUfoIn7eoKDI+3KQwiBPx4mGf4P0zE+pKjg7WEGhLBKCYWHakUANPbRns1EBtcJ+lPVOW3KbHdZdGsZowEBCkuo1sVPHEUTMZJ7NcFJjWOdXFzF4aKUVPKwKE+uzIMMy7YJCgES3JKdzMq/Cc2YHE1qeiijI1i/YZXZz8U5p1lJ7fcWeZYRK6pBP32+v6f8ffQ3DC+j4Z3VNWt1A8NugkvQ3JkC8+QdoYQ33gw4Ha2esdNeDkb5yFcVF6J2g62IxWp9Fy9uyChxrz1P5XMISnZ5xEVQiRumsAZVovUH5ZxwgrWbOLNaBQybF2W4U1OZa/nL+059ToQLBsYJq4f+JuMkyt6Vj5wVAXGjqT5ZCPK+X7QTeeuyTG0UB186kbRUnimZQVvgWMxz9VqeIfEChMSEO4NyOKe1YbmpVI2FKIbSbBetPar18T8MQKvSpc4MHjzstJptn1Oed20WZeWdWk9Rr8SZXbzXkMrl9n9TN0JpjP4kLHcaRTSWF5VJyAFPe2qwfXgaNUbhZgvZT7MF/IYXuobtp0NiT+G/UjkjFWWZSYBxdxkGpdT7m4aplMTRu0cxk68zv7HsOMNmcjWLeuG+N6AaEZpGzdSHSgnaFtgCWEHSwvlWi4DokzqPdKKKMv+SCXyx/dla8Yyh8Jf2kJILLaFux1zaJSHman1NTdk1im1b8zCzD+gWP2io46wWtpKRJA+308qHOAlAQo+ctLsAstT4tjMcF+9rOQZ6zvfzEF33kOiMNI3RZH17f3r0UR5PKQQqT1n1IZEYndM58jkOYIAeskBXTDVtR1VGqzzKPALFS/u5jDZvh0+uO/tVWeJd9C5QCa6Rcrr7JLFREDY8sEJEAX8vI9MbAyg4tiRGBCosviJ9M6uxa/AsgiZNtSnOr8EFcee0kZN6JS0GOYTghgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"1236922a-01a2-4982-952a-f71918c42280","timestamp":"2026-02-11T15:45:10.725Z"}
{"parentUuid":"1236922a-01a2-4982-952a-f71918c42280","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me read the remaining sections needed for findings #3-#10."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"cf325a4e-6fe5-424f-a8c2-d793e0cea062","timestamp":"2026-02-11T15:45:10.734Z"}
{"parentUuid":"cf325a4e-6fe5-424f-a8c2-d793e0cea062","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MDn2wXnMs5jTD1KsajxkpV","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":928,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"9332b05d-c222-46fd-9262-8326f8be7e39","timestamp":"2026-02-11T15:45:10.768Z"}
{"parentUuid":"9332b05d-c222-46fd-9262-8326f8be7e39","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01MDn2wXnMs5jTD1KsajxkpV","toolUseID":"toolu_01MDn2wXnMs5jTD1KsajxkpV","timestamp":"2026-02-11T15:45:10.772Z","uuid":"d7c4e9cd-6a02-4230-b416-893e94133b63"}
{"parentUuid":"9332b05d-c222-46fd-9262-8326f8be7e39","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MDn2wXnMs5jTD1KsajxkpV","type":"tool_result","content":"   928→    \"reference\": \"#1016\",\n   929→    \"amount_formatted\": \"65.45 EUR\"\n   930→  }\n   931→}\n   932→```\n   933→\n   934→**Error responses:**\n   935→- `404` - Checkout not found\n   936→- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n   937→- `422` - Validation errors (missing card fields, invalid payment method)\n   938→- `422` - Payment declined (credit card magic numbers)\n   939→\n   940→```json\n   941→{\n   942→  \"message\": \"Your card was declined.\","}]},"uuid":"60366008-6e8d-4273-9703-49116fdc5233","timestamp":"2026-02-11T15:45:10.771Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",","numLines":15,"startLine":928,"totalLines":3241}},"sourceToolAssistantUUID":"9332b05d-c222-46fd-9262-8326f8be7e39"}
{"parentUuid":"60366008-6e8d-4273-9703-49116fdc5233","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01V1QfNTBpgYd4M4fi6FHyCg","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":3113,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"640b7373-df9d-446c-9852-0c8c7c50f5fa","timestamp":"2026-02-11T15:45:10.804Z"}
{"parentUuid":"640b7373-df9d-446c-9852-0c8c7c50f5fa","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01V1QfNTBpgYd4M4fi6FHyCg","toolUseID":"toolu_01V1QfNTBpgYd4M4fi6FHyCg","timestamp":"2026-02-11T15:45:10.806Z","uuid":"2c623111-6de9-4cdf-b2a0-87683025c81b"}
{"parentUuid":"640b7373-df9d-446c-9852-0c8c7c50f5fa","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01V1QfNTBpgYd4M4fi6FHyCg","type":"tool_result","content":"  3113→| `POST /api/storefront/v1/carts*` | `api.storefront` |\n  3114→| `GET /api/storefront/v1/carts*` | `api.storefront` |\n  3115→| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n  3116→| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n  3117→| `POST /api/storefront/v1/checkouts*` | `checkout` |\n  3118→| `GET /api/storefront/v1/checkouts*` | `checkout` |\n  3119→| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n  3120→| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n  3121→| `POST /api/storefront/v1/payments*` | `checkout` |\n  3122→| `GET /api/storefront/v1/orders*` | `api.storefront` |\n  3123→| `GET /api/storefront/v1/search` | `search` |\n  3124→| `GET /api/storefront/v1/search/suggest` | `search` |\n  3125→| `POST /api/storefront/v1/analytics/events` | `analytics` |\n  3126→| `GET /api/admin/v1/*` | `api.admin` |\n  3127→| `POST /api/admin/v1/*` | `api.admin` |"}]},"uuid":"f9d5784a-a9ff-40d0-a1d6-c67e2654d4c1","timestamp":"2026-02-11T15:45:10.806Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |","numLines":15,"startLine":3113,"totalLines":3241}},"sourceToolAssistantUUID":"640b7373-df9d-446c-9852-0c8c7c50f5fa"}
{"parentUuid":"f9d5784a-a9ff-40d0-a1d6-c67e2654d4c1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DvPZPhpZFAAY47Yhh8N3xL","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","offset":85,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"2f10c749-63b5-4847-9a2a-7c806242f73f","timestamp":"2026-02-11T15:45:10.852Z"}
{"parentUuid":"2f10c749-63b5-4847-9a2a-7c806242f73f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01DvPZPhpZFAAY47Yhh8N3xL","toolUseID":"toolu_01DvPZPhpZFAAY47Yhh8N3xL","timestamp":"2026-02-11T15:45:10.855Z","uuid":"703cebfd-2d13-4218-ad27-03b20384f4f7"}
{"parentUuid":"2f10c749-63b5-4847-9a2a-7c806242f73f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DvPZPhpZFAAY47Yhh8N3xL","type":"tool_result","content":"    85→| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n    86→| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n    87→\n    88→#### Settings\n    89→\n    90→| Method | Path | Description | Component |\n    91→|--------|------|-------------|-----------|\n    92→| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n    93→| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n    94→| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n    95→\n    96→#### Themes and Online Store\n    97→\n    98→| Method | Path | Description | Component |\n    99→|--------|------|-------------|-----------|"}]},"uuid":"c4e22431-2591-4816-9600-2923dfa48cc7","timestamp":"2026-02-11T15:45:10.854Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","content":"| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|","numLines":15,"startLine":85,"totalLines":3241}},"sourceToolAssistantUUID":"2f10c749-63b5-4847-9a2a-7c806242f73f"}
{"parentUuid":"c4e22431-2591-4816-9600-2923dfa48cc7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015dYAm4NDehwASjQTjbB5Jh","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","offset":1416,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"65d40af9-1375-4f20-bab5-aa2474d6807f","timestamp":"2026-02-11T15:45:11.271Z"}
{"parentUuid":"65d40af9-1375-4f20-bab5-aa2474d6807f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_015dYAm4NDehwASjQTjbB5Jh","toolUseID":"toolu_015dYAm4NDehwASjQTjbB5Jh","timestamp":"2026-02-11T15:45:11.273Z","uuid":"438f8c17-30c4-476a-a99a-4386f21225a6"}
{"parentUuid":"65d40af9-1375-4f20-bab5-aa2474d6807f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015dYAm4NDehwASjQTjbB5Jh","type":"tool_result","content":"  1416→  - Default locale: Select (English/German/French and more)\n  1417→  - Timezone: Select (populated from PHP timezone list)\n  1418→\n  1419→Save button at the bottom (primary variant, right-aligned).\n  1420→\n  1421→### 11.2 Domains Settings\n  1422→\n  1423→Domains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.\n  1424→\n  1425→**Livewire component:** `App\\Livewire\\Admin\\Settings\\Domains`\n  1426→\n  1427→**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`\n  1428→\n  1429→#### Component Requirements\n  1430→"}]},"uuid":"b2f45bb3-5364-4f3d-aec9-80e0a2a70bda","timestamp":"2026-02-11T15:45:11.273Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","content":"  - Default locale: Select (English/German/French and more)\n  - Timezone: Select (populated from PHP timezone list)\n\nSave button at the bottom (primary variant, right-aligned).\n\n### 11.2 Domains Settings\n\nDomains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Domains`\n\n**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`\n\n#### Component Requirements\n","numLines":15,"startLine":1416,"totalLines":2429}},"sourceToolAssistantUUID":"65d40af9-1375-4f20-bab5-aa2474d6807f"}
{"parentUuid":"b2f45bb3-5364-4f3d-aec9-80e0a2a70bda","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019Bsm3j7QqDcVSqWAtF9SAH","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":1034,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"a31b4e6e-3c15-4530-915b-f195add0f669","timestamp":"2026-02-11T15:45:12.003Z"}
{"parentUuid":"a31b4e6e-3c15-4530-915b-f195add0f669","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_019Bsm3j7QqDcVSqWAtF9SAH","toolUseID":"toolu_019Bsm3j7QqDcVSqWAtF9SAH","timestamp":"2026-02-11T15:45:12.006Z","uuid":"ca9fb559-8f4a-4269-b832-f6a177cef66e"}
{"parentUuid":"a31b4e6e-3c15-4530-915b-f195add0f669","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019Bsm3j7QqDcVSqWAtF9SAH","type":"tool_result","content":"  1034→\n  1035→---\n  1036→\n  1037→## Suite 6: Admin Settings\n  1038→\n  1039→**File:** `tests/Browser/Admin/SettingsTest.php`\n  1040→\n  1041→**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n  1042→\n  1043→### Settings Page Structure\n  1044→\n  1045→The admin settings page at `/admin/settings` uses a tabbed interface:\n  1046→\n  1047→| Tab | Route | Content |\n  1048→|-----|-------|---------|"}]},"uuid":"77595230-91bc-49e2-848d-55596256650e","timestamp":"2026-02-11T15:45:12.006Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|","numLines":15,"startLine":1034,"totalLines":3174}},"sourceToolAssistantUUID":"a31b4e6e-3c15-4530-915b-f195add0f669"}
{"parentUuid":"77595230-91bc-49e2-848d-55596256650e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LkhtfVv9sSvnCmMsxgcpe7","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":3133,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"1c0cfd43-0c3a-4550-acad-fefb7fc9a1e1","timestamp":"2026-02-11T15:45:12.726Z"}
{"parentUuid":"1c0cfd43-0c3a-4550-acad-fefb7fc9a1e1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01LkhtfVv9sSvnCmMsxgcpe7","toolUseID":"toolu_01LkhtfVv9sSvnCmMsxgcpe7","timestamp":"2026-02-11T15:45:12.729Z","uuid":"ee46fae7-c644-49b1-a965-ef307096f58a"}
{"parentUuid":"1c0cfd43-0c3a-4550-acad-fefb7fc9a1e1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LkhtfVv9sSvnCmMsxgcpe7","type":"tool_result","content":"  3133→| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n  3134→| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n  3135→| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n  3136→| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n  3137→| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n  3138→| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n  3139→| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n  3140→| **Total** | **18 files** | **134** |\n  3141→\n  3142→---\n  3143→\n  3144→## Test Execution Commands\n  3145→\n  3146→| What | Command |\n  3147→|------|---------|"}]},"uuid":"f2b320a5-597e-472f-9ff8-97779d903a23","timestamp":"2026-02-11T15:45:12.728Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|","numLines":15,"startLine":3133,"totalLines":3174}},"sourceToolAssistantUUID":"1c0cfd43-0c3a-4550-acad-fefb7fc9a1e1"}
{"parentUuid":"f2b320a5-597e-472f-9ff8-97779d903a23","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013MCP8DA2YevDhQDEgYiH9j","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1644,"limit":20}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"cb545a12-6894-4ea0-9cec-392f80c46fe9","timestamp":"2026-02-11T15:45:13.495Z"}
{"parentUuid":"cb545a12-6894-4ea0-9cec-392f80c46fe9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_013MCP8DA2YevDhQDEgYiH9j","toolUseID":"toolu_013MCP8DA2YevDhQDEgYiH9j","timestamp":"2026-02-11T15:45:13.497Z","uuid":"ca31686c-c803-45e2-b65a-6d62de137ff6"}
{"parentUuid":"cb545a12-6894-4ea0-9cec-392f80c46fe9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013MCP8DA2YevDhQDEgYiH9j","type":"tool_result","content":"  1644→| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n  1645→\n  1646→---\n  1647→\n  1648→#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n  1649→\n  1650→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1651→|-----------|----------------------|----------------|----------|\n  1652→| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n  1653→| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n  1654→| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n  1655→| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n  1656→| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n  1657→| validates required address fields | PUT address with missing city | 422 response | validation |\n  1658→\n  1659→---\n  1660→\n  1661→#### tests/Feature/Api/AdminProductApiTest.php\n  1662→\n  1663→| Test Name | Setup / Preconditions | Key Assertions | Category |"}]},"uuid":"7acabaf3-6269-4830-a0ac-b5e2a375068c","timestamp":"2026-02-11T15:45:13.497Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |","numLines":20,"startLine":1644,"totalLines":2063}},"sourceToolAssistantUUID":"cb545a12-6894-4ea0-9cec-392f80c46fe9"}
{"parentUuid":"7acabaf3-6269-4830-a0ac-b5e2a375068c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MXpYeeSnqsiKDoyDX7GGSt","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1942,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"aba2fd85-bd79-4df5-ae71-db974cfcbb94","timestamp":"2026-02-11T15:45:14.225Z"}
{"parentUuid":"aba2fd85-bd79-4df5-ae71-db974cfcbb94","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01MXpYeeSnqsiKDoyDX7GGSt","toolUseID":"toolu_01MXpYeeSnqsiKDoyDX7GGSt","timestamp":"2026-02-11T15:45:14.228Z","uuid":"32afabca-ae16-482c-b30e-bd4c9a74215c"}
{"parentUuid":"aba2fd85-bd79-4df5-ae71-db974cfcbb94","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MXpYeeSnqsiKDoyDX7GGSt","type":"tool_result","content":"  1942→- Configure exception handling\n  1943→\n  1944→### Routes Files\n  1945→\n  1946→| File | Contents |\n  1947→|------|----------|\n  1948→| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n  1949→| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n  1950→| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n  1951→\n  1952→### Service Providers\n  1953→\n  1954→**AppServiceProvider::boot() responsibilities:**\n  1955→- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n  1956→- Register rate limiters"}]},"uuid":"4d1881ae-c533-4b39-b995-bb5253a12b0f","timestamp":"2026-02-11T15:45:14.228Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters","numLines":15,"startLine":1942,"totalLines":2063}},"sourceToolAssistantUUID":"aba2fd85-bd79-4df5-ae71-db974cfcbb94"}
{"parentUuid":"4d1881ae-c533-4b39-b995-bb5253a12b0f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JxrvwFRMukSzrPbSQVJ7BH","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":2007,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"51801ccd-120f-493c-b5cc-e83fd9565af8","timestamp":"2026-02-11T15:45:14.897Z"}
{"parentUuid":"51801ccd-120f-493c-b5cc-e83fd9565af8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01JxrvwFRMukSzrPbSQVJ7BH","toolUseID":"toolu_01JxrvwFRMukSzrPbSQVJ7BH","timestamp":"2026-02-11T15:45:14.899Z","uuid":"7b782190-3dec-44df-8612-9e26106faf71"}
{"parentUuid":"51801ccd-120f-493c-b5cc-e83fd9565af8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JxrvwFRMukSzrPbSQVJ7BH","type":"tool_result","content":"  2007→| Contracts/Interfaces | 2-3 |\n  2008→| Traits/Concerns | 2-3 |\n  2009→| Scopes | 1 |\n  2010→| Observers | 1 |\n  2011→| Support classes | 2-3 |\n  2012→| Tests (Unit) | 6 |\n  2013→| Tests (Feature) | 28-30 |\n  2014→| Tests (Browser) | 18 files (134 tests) |\n  2015→| CSS/JS | 3-5 |\n  2016→| **Total** | **~320-380 files** |\n  2017→\n  2018→---\n  2019→\n  2020→## 7. Verification Checklist\n  2021→"}]},"uuid":"41b2fa10-bed2-441a-9b3a-7d67fceb3ecc","timestamp":"2026-02-11T15:45:14.899Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n","numLines":15,"startLine":2007,"totalLines":2063}},"sourceToolAssistantUUID":"51801ccd-120f-493c-b5cc-e83fd9565af8"}
{"parentUuid":"41b2fa10-bed2-441a-9b3a-7d67fceb3ecc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Q27sGsMAkPfMnGTrkXtMkt","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","offset":610,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"1016896e-32d4-42b9-80be-dcc72d753691","timestamp":"2026-02-11T15:45:15.570Z"}
{"parentUuid":"1016896e-32d4-42b9-80be-dcc72d753691","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01Q27sGsMAkPfMnGTrkXtMkt","toolUseID":"toolu_01Q27sGsMAkPfMnGTrkXtMkt","timestamp":"2026-02-11T15:45:15.573Z","uuid":"b7b4ec79-e194-4206-bc7f-a9009f935bc2"}
{"parentUuid":"1016896e-32d4-42b9-80be-dcc72d753691","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Q27sGsMAkPfMnGTrkXtMkt","type":"tool_result","content":"   610→|-------------|-----------------|-------------|\n   611→| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n   612→| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n   613→| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n   614→| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n   615→| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n   616→| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n   617→| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |\n   618→\n   619→---\n   620→\n   621→### 3.3 Custom Middleware Specifications\n   622→\n   623→#### ResolveStore\n   624→"}]},"uuid":"112218d9-a5b7-4558-b8c5-8d939560bacc","timestamp":"2026-02-11T15:45:15.572Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"|-------------|-----------------|-------------|\n| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |\n\n---\n\n### 3.3 Custom Middleware Specifications\n\n#### ResolveStore\n","numLines":15,"startLine":610,"totalLines":1111}},"sourceToolAssistantUUID":"1016896e-32d4-42b9-80be-dcc72d753691"}
{"parentUuid":"112218d9-a5b7-4558-b8c5-8d939560bacc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JZWh8safPFcYdU1De6dffG","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","offset":737,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"ba2e9a15-969f-4c7a-ac47-232695cc5018","timestamp":"2026-02-11T15:45:16.228Z"}
{"parentUuid":"ba2e9a15-969f-4c7a-ac47-232695cc5018","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01JZWh8safPFcYdU1De6dffG","toolUseID":"toolu_01JZWh8safPFcYdU1De6dffG","timestamp":"2026-02-11T15:45:16.233Z","uuid":"2cda86be-2e8a-42e9-aaba-24a4586a6798"}
{"parentUuid":"ba2e9a15-969f-4c7a-ac47-232695cc5018","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JZWh8safPFcYdU1De6dffG","type":"tool_result","content":"   737→|-------------|-------|----------|------------|\n   738→| `login` | 5 per minute | IP address | Admin login, customer login |\n   739→| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |\n   740→| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |\n   741→| `checkout` | 10 per minute | Session ID | Checkout-related operations |\n   742→| `search` | 30 per minute | IP address | Search queries |\n   743→| `analytics` | 60 per minute | IP address | Analytics event ingestion |\n   744→| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |\n   745→\n   746→**Rate limit response headers** (returned on all API responses by Laravel default):\n   747→\n   748→| Header | Description |\n   749→|--------|-------------|\n   750→| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n   751→| `X-RateLimit-Remaining` | Requests remaining in the current window |"}]},"uuid":"a5684e4b-ef90-425a-ba95-6beede5f2d32","timestamp":"2026-02-11T15:45:16.233Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","content":"|-------------|-------|----------|------------|\n| `login` | 5 per minute | IP address | Admin login, customer login |\n| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |\n| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |\n| `checkout` | 10 per minute | Session ID | Checkout-related operations |\n| `search` | 30 per minute | IP address | Search queries |\n| `analytics` | 60 per minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |\n\n**Rate limit response headers** (returned on all API responses by Laravel default):\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |","numLines":15,"startLine":737,"totalLines":1111}},"sourceToolAssistantUUID":"ba2e9a15-969f-4c7a-ac47-232695cc5018"}
{"parentUuid":"a5684e4b-ef90-425a-ba95-6beede5f2d32","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0152ESdjxtVeDNTs5bJPfsFm","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","offset":1080,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"395c3b2f-1cb5-488f-83ad-20e0c9fa27c0","timestamp":"2026-02-11T15:45:16.898Z"}
{"parentUuid":"395c3b2f-1cb5-488f-83ad-20e0c9fa27c0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_0152ESdjxtVeDNTs5bJPfsFm","toolUseID":"toolu_0152ESdjxtVeDNTs5bJPfsFm","timestamp":"2026-02-11T15:45:16.905Z","uuid":"880339c4-2672-42aa-8ea1-08b4a4dee8f3"}
{"parentUuid":"395c3b2f-1cb5-488f-83ad-20e0c9fa27c0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0152ESdjxtVeDNTs5bJPfsFm","type":"tool_result","content":"  1080→| Expiry | Text (MM/YY format) | Yes | Valid future date |\n  1081→| CVC | Text (3-4 digits) | Yes | 3-4 digits |\n  1082→\n  1083→**PayPal form (shown when PayPal is selected):**\n  1084→\n  1085→```\n  1086→+--------------------------------------------------------------+\n  1087→| You will be redirected to complete your PayPal payment.       |\n  1088→| (mock: payment is processed instantly)                        |\n  1089→|                                                               |\n  1090→| [Pay with PayPal - 29.98 EUR]                                |\n  1091→+--------------------------------------------------------------+\n  1092→```\n  1093→\n  1094→No additional form fields. The button text changes to \"Pay with PayPal - {total}\"."}]},"uuid":"e7b33b1f-ba4c-48e4-859a-7e037f426e31","timestamp":"2026-02-11T15:45:16.905Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","content":"| Expiry | Text (MM/YY format) | Yes | Valid future date |\n| CVC | Text (3-4 digits) | Yes | 3-4 digits |\n\n**PayPal form (shown when PayPal is selected):**\n\n```\n+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Pay with PayPal - {total}\".","numLines":15,"startLine":1080,"totalLines":1924}},"sourceToolAssistantUUID":"395c3b2f-1cb5-488f-83ad-20e0c9fa27c0"}
{"parentUuid":"e7b33b1f-ba4c-48e4-859a-7e037f426e31","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011u3nopLE3bc26VR6K5hh4u","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":643,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"b586d564-884a-48cf-b84d-74659deeba7b","timestamp":"2026-02-11T15:45:17.565Z"}
{"parentUuid":"b586d564-884a-48cf-b84d-74659deeba7b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_011u3nopLE3bc26VR6K5hh4u","toolUseID":"toolu_011u3nopLE3bc26VR6K5hh4u","timestamp":"2026-02-11T15:45:17.569Z","uuid":"ac358104-e073-4606-93e4-1d8b486bdd8d"}
{"parentUuid":"b586d564-884a-48cf-b84d-74659deeba7b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011u3nopLE3bc26VR6K5hh4u","type":"tool_result","content":"   643→\n   644→| Enum | Values |\n   645→|------|--------|\n   646→| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n   647→| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n   648→| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n   649→| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n   650→| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n   651→| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n   652→| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n   653→\n   654→#### Step 5.3: Payment Service (Mock PSP)\n   655→\n   656→**Files to create:**\n   657→"}]},"uuid":"9656b90c-383b-4434-85f8-37479ffa4408","timestamp":"2026-02-11T15:45:17.568Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n","numLines":15,"startLine":643,"totalLines":2063}},"sourceToolAssistantUUID":"b586d564-884a-48cf-b84d-74659deeba7b"}
{"parentUuid":"9656b90c-383b-4434-85f8-37479ffa4408","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01D3tP4WTAu14grV2AeRT9zs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RMn5tVDWCE9dna15EPZtzb","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":679,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2812,"cache_read_input_tokens":50105,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2812},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YHHzqSdjTjmKFR9SDm","type":"assistant","uuid":"5ffb1c77-9cec-4549-9fd1-04f8c1255b46","timestamp":"2026-02-11T15:45:18.229Z"}
{"parentUuid":"5ffb1c77-9cec-4549-9fd1-04f8c1255b46","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01RMn5tVDWCE9dna15EPZtzb","toolUseID":"toolu_01RMn5tVDWCE9dna15EPZtzb","timestamp":"2026-02-11T15:45:18.234Z","uuid":"055a0095-8fe8-4bca-9e15-018dc5e2f9fc"}
{"parentUuid":"5ffb1c77-9cec-4549-9fd1-04f8c1255b46","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RMn5tVDWCE9dna15EPZtzb","type":"tool_result","content":"   679→\n   680→#### Step 5.3b: Admin Payment Confirmation\n   681→\n   682→**Files to create:**\n   683→\n   684→| Type | Name | Purpose |\n   685→|------|------|---------|\n   686→| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n   687→\n   688→**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n   689→1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n   690→2. Update `financial_status` to `paid`, `status` to `paid`\n   691→3. Update payment record status to `captured`\n   692→4. Commit reserved inventory\n   693→5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment"}]},"uuid":"c2163d79-3f98-4eae-9d44-dcab159423a6","timestamp":"2026-02-11T15:45:18.233Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment","numLines":15,"startLine":679,"totalLines":2063}},"sourceToolAssistantUUID":"5ffb1c77-9cec-4549-9fd1-04f8c1255b46"}
{"parentUuid":"c2163d79-3f98-4eae-9d44-dcab159423a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01L4ztWsaJr6QQd6iQub7CQ8","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have all the context I need. Let me also read a bit more of the 05-BUSINESS-LOGIC.md around the transition section to understand the full block that needs fixing.","signature":"EtECCkYICxgCKkBiUmJQ8aCF4JMmYwIEzUm5q/wgAa3DzHOspYAtjkrAhNEQqz3WvFzfzdgTGSs20Or+4i/3EQHad9+l3D/WFsL2EgyxH0oWwYGgU5xdougaDPKY/1Vzzr7neIxkXyIw5+CG9TUPqnfNdgo5YdVrF2n0yGKrLGOSxm6V/QrFaBhKlYLOc4EhFpwS2rVYeATHKrgBDJHsk48C7aASbhxX00sRQUUi5VvwIrlHsYA2TmYfwye30bBTHAntEqgfJunPBucbfK79mZZTH8dp3v34td03SzijawzaSmzPKZLfsHl3yadZ5eC+9mUPWO6y9rNEn5hdY5PPSPuy13B4YYqYjIaq8nmmtRx416bSXAIWUQso9I1BpeaurIKVfr18rZ/aLLLGMAGH5rBKtaGbxKeRpXm7N9BiSOOIfHVtG3rgj1keo44dBIE5CPxrrRgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5983,"cache_read_input_tokens":52917,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YJeTRoDsge1pjLDBAv","type":"assistant","uuid":"dedf90ab-83bc-45d5-ab18-d532b4de73f9","timestamp":"2026-02-11T15:45:21.772Z"}
{"parentUuid":"dedf90ab-83bc-45d5-ab18-d532b4de73f9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01L4ztWsaJr6QQd6iQub7CQ8","type":"message","role":"assistant","content":[{"type":"text","text":"Now I have all the context. Let me also read a bit more around the business logic transition to see the full block."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5983,"cache_read_input_tokens":52917,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YJeTRoDsge1pjLDBAv","type":"assistant","uuid":"f4a51b31-9e94-4673-a3bb-8d160d08dce7","timestamp":"2026-02-11T15:45:22.541Z"}
{"parentUuid":"f4a51b31-9e94-4673-a3bb-8d160d08dce7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01L4ztWsaJr6QQd6iQub7CQ8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019LyswSZbKQHdcoKXH1F9TH","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":487,"limit":25}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5983,"cache_read_input_tokens":52917,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YJeTRoDsge1pjLDBAv","type":"assistant","uuid":"f16b2bb5-1015-4ce2-af22-e4b186b55d92","timestamp":"2026-02-11T15:45:23.893Z"}
{"parentUuid":"f16b2bb5-1015-4ce2-af22-e4b186b55d92","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_019LyswSZbKQHdcoKXH1F9TH","toolUseID":"toolu_019LyswSZbKQHdcoKXH1F9TH","timestamp":"2026-02-11T15:45:23.899Z","uuid":"212c450a-e5e7-4b55-8d23-b16931826475"}
{"parentUuid":"f16b2bb5-1015-4ce2-af22-e4b186b55d92","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019LyswSZbKQHdcoKXH1F9TH","type":"tool_result","content":"   487→#### Transition: shipping_selected -> payment_pending\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_pending`.\n   501→\n   502→#### Transition: payment_pending -> 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→"}]},"uuid":"41ae222b-8be9-42cb-9d11-784abc74a22a","timestamp":"2026-02-11T15:45:23.898Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n","numLines":25,"startLine":487,"totalLines":1710}},"sourceToolAssistantUUID":"f16b2bb5-1015-4ce2-af22-e4b186b55d92"}
{"parentUuid":"41ae222b-8be9-42cb-9d11-784abc74a22a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01L4ztWsaJr6QQd6iQub7CQ8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011RBi1JafvnEW93Pm5FtXo9","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":995,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5983,"cache_read_input_tokens":52917,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5983},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YJeTRoDsge1pjLDBAv","type":"assistant","uuid":"ac43b295-f1a9-48c1-b414-f0240b3cd2ce","timestamp":"2026-02-11T15:45:24.594Z"}
{"parentUuid":"ac43b295-f1a9-48c1-b414-f0240b3cd2ce","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_011RBi1JafvnEW93Pm5FtXo9","toolUseID":"toolu_011RBi1JafvnEW93Pm5FtXo9","timestamp":"2026-02-11T15:45:24.598Z","uuid":"cc9810aa-6547-4843-8ec2-aca23efe9367"}
{"parentUuid":"ac43b295-f1a9-48c1-b414-f0240b3cd2ce","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011RBi1JafvnEW93Pm5FtXo9","type":"tool_result","content":"   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"}]},"uuid":"3781d526-068f-4759-aaa5-757632eb265e","timestamp":"2026-02-11T15:45:24.598Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received","numLines":15,"startLine":995,"totalLines":1710}},"sourceToolAssistantUUID":"ac43b295-f1a9-48c1-b414-f0240b3cd2ce"}
{"parentUuid":"3781d526-068f-4759-aaa5-757632eb265e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MPxZU9TQtEovHWpuBYYfRn","type":"message","role":"assistant","content":[{"type":"text","text":"Good, now I have all the information. Let me apply all 10 fixes. I'll start with the two critical issues and work through the rest.\n\n**Finding #1 (Critical):** Align `payment_pending` to `payment_selected` in specs 01, 05, 09."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":920,"cache_read_input_tokens":58900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":920},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YK7k1KEjqbPs4pvpEB","type":"assistant","uuid":"61ea5bc1-bee6-4e51-8560-fde9f1e943e4","timestamp":"2026-02-11T15:45:28.848Z"}
{"parentUuid":"61ea5bc1-bee6-4e51-8560-fde9f1e943e4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MPxZU9TQtEovHWpuBYYfRn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014tcsjoZdKhdffL859dG6QS","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","old_string":"| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |","new_string":"| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":920,"cache_read_input_tokens":58900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":920},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YK7k1KEjqbPs4pvpEB","type":"assistant","uuid":"4d04b468-1816-42c2-b0c0-59807c5f6118","timestamp":"2026-02-11T15:45:31.070Z"}
{"parentUuid":"4d04b468-1816-42c2-b0c0-59807c5f6118","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_014tcsjoZdKhdffL859dG6QS","toolUseID":"toolu_014tcsjoZdKhdffL859dG6QS","timestamp":"2026-02-11T15:45:31.076Z","uuid":"fffb7c25-c7bc-4617-ace8-f9f9b1d10b59"}
{"parentUuid":"4d04b468-1816-42c2-b0c0-59807c5f6118","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014tcsjoZdKhdffL859dG6QS","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md has been updated successfully."}]},"uuid":"5b1141a0-1882-4e1f-a78c-eb5d7490c651","timestamp":"2026-02-11T15:45:31.076Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","oldString":"| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |","newString":"| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |","originalFile":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID\n- `idx_payments_method` on (`method`) - Filtering payments by method\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**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\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT payment_method\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT method\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","structuredPatch":[{"oldStart":584,"oldLines":7,"newStart":584,"newLines":7,"lines":[" | store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |"," | cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |"," | customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |","-| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_pending', 'completed', 'expired') | |","+| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |"," | payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |"," | email | TEXT | Yes | NULL | - | Contact email |"," | shipping_address_json | TEXT | Yes | NULL | - | JSON address object |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"4d04b468-1816-42c2-b0c0-59807c5f6118"}
{"parentUuid":"5b1141a0-1882-4e1f-a78c-eb5d7490c651","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MPxZU9TQtEovHWpuBYYfRn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EhAkP3LpjLMgrP4CGiP6tN","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","old_string":"| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |","new_string":"| status | TEXT | No | 'pending' | CHECK IN ('pending', 'captured', 'failed', 'refunded') | |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":920,"cache_read_input_tokens":58900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":920},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YK7k1KEjqbPs4pvpEB","type":"assistant","uuid":"fb974e2d-dc37-4dd0-8366-72d2e3bc1503","timestamp":"2026-02-11T15:45:32.327Z"}
{"parentUuid":"fb974e2d-dc37-4dd0-8366-72d2e3bc1503","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01EhAkP3LpjLMgrP4CGiP6tN","toolUseID":"toolu_01EhAkP3LpjLMgrP4CGiP6tN","timestamp":"2026-02-11T15:45:32.333Z","uuid":"f1951c25-ce20-4766-8bbe-7bf35d7f561d"}
{"parentUuid":"fb974e2d-dc37-4dd0-8366-72d2e3bc1503","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EhAkP3LpjLMgrP4CGiP6tN","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md has been updated successfully."}]},"uuid":"1b50c770-be31-4f16-899a-c69561e2ffb8","timestamp":"2026-02-11T15:45:32.333Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","oldString":"| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |","newString":"| status | TEXT | No | 'pending' | CHECK IN ('pending', 'captured', 'failed', 'refunded') | |","originalFile":"# 01 - Database Schema Specification\n\n> Shopify-clone e-commerce platform - SQLite database schema for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n| Enumerations | Check constraints on text columns |\n| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n\n---\n\n## Epic 1 - Foundation and Multi-Store Tenancy\n\n### organizations\n\nBilling entity that owns one or more stores.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | Organization display name |\n| billing_email | TEXT | No | - | - | Primary billing contact |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n\n---\n\n### stores\n\nIndividual storefront belonging to an organization.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n| name | TEXT | No | - | - | Store display name |\n| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n\n---\n\n### store_domains\n\nCustom domains attached to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n\n---\n\n### users\n\nAdmin/staff users who manage stores (not storefront customers).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| email | TEXT | No | - | UNIQUE per table | Login email |\n| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n| name | TEXT | No | - | - | Display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n- `idx_users_status` on (`status`) - Filtering users by active/disabled\n\n**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\n---\n\n### store_users\n\nPivot table linking users to stores with a role. Uses composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `user_id`)\n\n**Indexes:**\n\n- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n\n---\n\n### store_settings\n\nSingle-row-per-store JSON settings bag.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n\n---\n\n## Epic 2 - Catalog, Collections, Inventory, and Media\n\n### products\n\nCore product record, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Product display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n| product_type | TEXT | Yes | NULL | - | Category classification |\n| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n\n**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n\n---\n\n### product_options\n\nNamed option dimensions for a product (e.g. Size, Color).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n\n---\n\n### product_option_values\n\nIndividual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n\n**Indexes:**\n\n- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n\n---\n\n### product_variants\n\nPurchasable SKU-level variant of a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n\n**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n\n---\n\n### variant_option_values\n\nMaps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n\n**Primary Key:** (`variant_id`, `product_option_value_id`)\n\n**Indexes:**\n\n- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n\n**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\n---\n\n### inventory_items\n\nStock tracking per variant per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n\n**Indexes:**\n\n- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n\n**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\n---\n\n### collections\n\nCurated or rule-based product groupings.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Collection display title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| description_html | TEXT | Yes | NULL | - | Rich-text description |\n| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n\n---\n\n### collection_products\n\nPivot linking products to collections with ordering. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n| position | INTEGER | No | 0 | - | Display ordering within collection |\n\n**Primary Key:** (`collection_id`, `product_id`)\n\n**Indexes:**\n\n- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n\n---\n\n### product_media\n\nImages and videos attached to a product.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n| width | INTEGER | Yes | NULL | - | Pixel width |\n| height | INTEGER | Yes | NULL | - | Pixel height |\n| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n| position | INTEGER | No | 0 | - | Display ordering |\n| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n- `idx_product_media_status` on (`status`) - Finding media by processing status\n\n---\n\n## Epic 3 - Themes, Storefront, and Localization\n\n### themes\n\nStorefront theme definitions per store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Theme display name |\n| version | TEXT | Yes | NULL | - | Semantic version string |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n\n---\n\n### theme_files\n\nIndividual files belonging to a theme (templates, assets, snippets).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n| storage_key | TEXT | No | - | - | File storage path/key |\n| sha256 | TEXT | No | - | - | Content hash for cache busting |\n| byte_size | INTEGER | No | 0 | - | File size in bytes |\n\n**Indexes:**\n\n- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n\n---\n\n### theme_settings\n\nSingle-row JSON configuration per theme.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n| settings_json | TEXT | No | '{}' | - | JSON settings object |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### pages\n\nStatic CMS pages (About Us, Contact, etc.).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| title | TEXT | No | - | - | Page title |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n| body_html | TEXT | Yes | NULL | - | Rich-text content |\n| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n\n---\n\n### navigation_menus\n\nNamed menus (e.g. main-menu, footer-menu).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n| title | TEXT | No | - | - | Display title |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n\n---\n\n### navigation_items\n\nIndividual links within a navigation menu (supports nesting via position).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n| label | TEXT | No | - | - | Display label |\n| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n| position | INTEGER | No | 0 | - | Display ordering |\n\n**Indexes:**\n\n- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n\n**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\n---\n\n## Epic 4 - Search\n\n### search_settings\n\nPer-store search configuration (synonyms, stop words).\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### search_queries\n\nSearch query log for analytics and autocomplete.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| query | TEXT | No | - | - | Search query text |\n| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n| results_count | INTEGER | No | 0 | - | Number of results returned |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n\n---\n\n## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n\n### carts\n\nShopping cart, optionally linked to a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_carts_store_id` on (`store_id`) - FK lookup\n- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n\n**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n\n---\n\n### cart_lines\n\nIndividual line items within a cart.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n\n**Indexes:**\n\n- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n\n**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\n---\n\n### checkouts\n\nCheckout session progressing through address, shipping, payment steps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |\n| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n| email | TEXT | Yes | NULL | - | Contact email |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n\n**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\n---\n\n### shipping_zones\n\nGeographic shipping zones for a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| name | TEXT | No | - | - | Zone display name |\n| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n\n**Indexes:**\n\n- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n\n**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n\n---\n\n### shipping_rates\n\nShipping rate options within a zone.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n| name | TEXT | No | - | - | Rate display name |\n| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n\n**Indexes:**\n\n- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n\n**Notes:** `config_json` structure depends on `type`:\n- Flat: `{\"amount\": 799}`\n- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n---\n\n### tax_settings\n\nPer-store tax configuration.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n| config_json | TEXT | No | '{}' | - | Provider-specific config |\n\n---\n\n### discounts\n\nDiscount codes and automatic discounts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n| usage_count | INTEGER | No | 0 | - | Current redemption count |\n| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n- `idx_discounts_store_id` on (`store_id`) - FK lookup\n- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n\n**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\n---\n\n## Epic 6 - Payments, Orders, Fulfillment, and Customers\n\n### customers\n\nStorefront customer accounts, scoped to a store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n| name | TEXT | Yes | NULL | - | Display name |\n| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n\n**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n\n---\n\n### customer_addresses\n\nSaved addresses for a customer.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n\n**Indexes:**\n\n- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n\n**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n\n---\n\n### orders\n\nPlaced orders with financial and fulfillment tracking.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n| email | TEXT | Yes | NULL | - | Contact email at time of order |\n| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n- `idx_orders_store_id` on (`store_id`) - FK lookup\n- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n\n---\n\n### order_lines\n\nIndividual items within an order. Contains snapshots of product data at time of purchase.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n| quantity | INTEGER | No | 1 | - | Number of units |\n| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n\n**Indexes:**\n\n- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n\n**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\n---\n\n### payments\n\nPayment attempts and captures for an order.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |\n| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |\n| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID\n- `idx_payments_method` on (`method`) - Filtering payments by method\n- `idx_payments_status` on (`status`) - Filtering payments by status\n\n**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\n---\n\n### refunds\n\nRefund records linked to an order and payment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n| reason | TEXT | Yes | NULL | - | Refund reason |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n- `idx_refunds_status` on (`status`) - Filtering refunds by status\n\n---\n\n### fulfillments\n\nShipment tracking for order fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n\n---\n\n### fulfillment_lines\n\nLinks specific order lines and quantities to a fulfillment.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n\n**Indexes:**\n\n- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n\n**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\n---\n\n## Epic 7 - Analytics\n\n### analytics_events\n\nRaw event stream for storefront analytics.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| type | TEXT | No | - | - | Event type (see notes) |\n| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n\n**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\n---\n\n### analytics_daily\n\nPre-aggregated daily metrics per store. Composite primary key.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n| visits_count | INTEGER | No | 0 | - | Unique sessions |\n| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n\n**Primary Key:** (`store_id`, `date`)\n\n**Indexes:**\n\n- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n\n**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\n---\n\n## Epic 8 - Apps and Webhooks\n\n### apps\n\nThird-party or first-party application registry.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| name | TEXT | No | - | - | App display name |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_apps_status` on (`status`) - Filtering apps by status\n\n---\n\n### app_installations\n\nInstallation of an app on a specific store.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Indexes:**\n\n- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n\n---\n\n### oauth_clients\n\nOAuth2 client credentials for apps.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n\n**Indexes:**\n\n- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n\n---\n\n### oauth_tokens\n\nIssued access/refresh tokens for app installations.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n\n**Indexes:**\n\n- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n\n---\n\n### webhook_subscriptions\n\nRegistered webhook endpoints for event delivery.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n| event_type | TEXT | No | - | - | Event to subscribe to |\n| target_url | TEXT | No | - | - | Delivery endpoint URL |\n| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n\n**Indexes:**\n\n- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n\n---\n\n### webhook_deliveries\n\nDelivery log for webhook attempts.\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n| event_id | TEXT | No | - | - | Unique event identifier |\n| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n\n**Indexes:**\n\n- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n\n---\n\n## Laravel Framework Tables\n\nThe following tables are managed by Laravel's built-in migrations and should not be modified.\n\n### sessions\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Session ID |\n| user_id | INTEGER | Yes | NULL | - | Associated user |\n| ip_address | TEXT | Yes | NULL | - | Client IP |\n| user_agent | TEXT | Yes | NULL | - | Client user agent |\n| payload | TEXT | No | - | - | Serialized session data |\n| last_activity | INTEGER | No | - | - | Unix timestamp |\n\n### cache\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Cache key |\n| value | TEXT | No | - | - | Serialized cache value |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### cache_locks\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| key | TEXT | No | - | PRIMARY KEY | Lock key |\n| owner | TEXT | No | - | - | Lock owner identifier |\n| expiration | INTEGER | No | - | - | Unix timestamp |\n\n### jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| attempts | INTEGER | No | - | - | Number of attempts |\n| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| available_at | INTEGER | No | - | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n\n### job_batches\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n| name | TEXT | No | - | - | Batch name |\n| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n| pending_jobs | INTEGER | No | - | - | Pending count |\n| failed_jobs | INTEGER | No | - | - | Failed count |\n| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n| options | TEXT | Yes | NULL | - | Serialized batch options |\n| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n| created_at | INTEGER | No | - | - | Unix timestamp |\n| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n\n### failed_jobs\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| uuid | TEXT | No | - | UNIQUE | Job UUID |\n| connection | TEXT | No | - | - | Queue connection name |\n| queue | TEXT | No | - | - | Queue name |\n| payload | TEXT | No | - | - | Serialized job payload |\n| exception | TEXT | No | - | - | Exception trace |\n| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n\n### personal_access_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n| tokenable_type | TEXT | No | - | - | Polymorphic type |\n| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n| name | TEXT | No | - | - | Token name |\n| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n### password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n---\n\n### customer_password_reset_tokens\n\n| Column | Type | Nullable | Default | Constraints | Notes |\n|---|---|---|---|---|---|\n| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n| email | TEXT | No | - | - | Customer email |\n| token | TEXT | No | - | - | Hashed reset token |\n| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n\n**Primary Key:** (`store_id`, `email`)\n\n---\n\n## Entity Relationship Diagrams\n\n### Epic 1 - Foundation and Tenancy\n\n```mermaid\nerDiagram\n    organizations ||--o{ stores : \"owns\"\n    stores ||--o{ store_domains : \"has\"\n    stores ||--o{ store_users : \"links\"\n    users ||--o{ store_users : \"links\"\n    stores ||--|| store_settings : \"has\"\n\n    organizations {\n        INTEGER id PK\n        TEXT name\n        TEXT billing_email\n    }\n    stores {\n        INTEGER id PK\n        INTEGER organization_id FK\n        TEXT name\n        TEXT handle\n        TEXT status\n    }\n    store_domains {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT hostname\n        TEXT type\n        INTEGER is_primary\n    }\n    users {\n        INTEGER id PK\n        TEXT email\n        TEXT password_hash\n        TEXT name\n        TEXT status\n    }\n    store_users {\n        INTEGER store_id PK\n        INTEGER user_id PK\n        TEXT role\n    }\n    store_settings {\n        INTEGER store_id PK\n        TEXT settings_json\n    }\n```\n\n### Epic 2 - Catalog, Collections, Inventory, and Media\n\n```mermaid\nerDiagram\n    stores ||--o{ products : \"has\"\n    stores ||--o{ collections : \"has\"\n    products ||--o{ product_options : \"defines\"\n    product_options ||--o{ product_option_values : \"contains\"\n    products ||--o{ product_variants : \"has\"\n    product_variants }o--o{ product_option_values : \"variant_option_values\"\n    product_variants ||--|| inventory_items : \"tracked by\"\n    stores ||--o{ inventory_items : \"has\"\n    collections }o--o{ products : \"collection_products\"\n    products ||--o{ product_media : \"has\"\n\n    products {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    product_options {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT name\n        INTEGER position\n    }\n    product_option_values {\n        INTEGER id PK\n        INTEGER product_option_id FK\n        TEXT value\n        INTEGER position\n    }\n    product_variants {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT sku\n        INTEGER price_amount\n        TEXT status\n    }\n    inventory_items {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER variant_id FK\n        INTEGER quantity_on_hand\n        INTEGER quantity_reserved\n        TEXT policy\n    }\n    collections {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT type\n        TEXT status\n    }\n    product_media {\n        INTEGER id PK\n        INTEGER product_id FK\n        TEXT type\n        TEXT storage_key\n        INTEGER position\n        TEXT status\n    }\n```\n\n### Epic 3 - Themes, Storefront, and Localization\n\n```mermaid\nerDiagram\n    stores ||--o{ themes : \"has\"\n    themes ||--o{ theme_files : \"contains\"\n    themes ||--|| theme_settings : \"configured by\"\n    stores ||--o{ pages : \"has\"\n    stores ||--o{ navigation_menus : \"has\"\n    navigation_menus ||--o{ navigation_items : \"contains\"\n\n    themes {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT name\n        TEXT status\n    }\n    theme_files {\n        INTEGER id PK\n        INTEGER theme_id FK\n        TEXT path\n        TEXT storage_key\n    }\n    theme_settings {\n        INTEGER theme_id PK\n        TEXT settings_json\n    }\n    pages {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT title\n        TEXT handle\n        TEXT status\n    }\n    navigation_menus {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT handle\n        TEXT title\n    }\n    navigation_items {\n        INTEGER id PK\n        INTEGER menu_id FK\n        TEXT type\n        TEXT label\n        INTEGER position\n    }\n```\n\n### Epics 5-6 - Cart, Checkout, Orders, and Payments\n\n```mermaid\nerDiagram\n    stores ||--o{ customers : \"has\"\n    customers ||--o{ customer_addresses : \"has\"\n    stores ||--o{ carts : \"has\"\n    customers ||--o{ carts : \"owns\"\n    carts ||--o{ cart_lines : \"contains\"\n    product_variants ||--o{ cart_lines : \"referenced by\"\n    stores ||--o{ checkouts : \"has\"\n    carts ||--o{ checkouts : \"converted to\"\n    stores ||--o{ orders : \"has\"\n    customers ||--o{ orders : \"placed\"\n    orders ||--o{ order_lines : \"contains\"\n    orders ||--o{ payments : \"paid via\"\n    orders ||--o{ refunds : \"refunded via\"\n    payments ||--o{ refunds : \"source of\"\n    orders ||--o{ fulfillments : \"shipped via\"\n    fulfillments ||--o{ fulfillment_lines : \"contains\"\n    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n\n    customers {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT email\n        TEXT name\n    }\n    carts {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT status\n    }\n    orders {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER customer_id FK\n        TEXT order_number\n        TEXT payment_method\n        TEXT status\n        TEXT financial_status\n        TEXT fulfillment_status\n        INTEGER total_amount\n    }\n    payments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT provider\n        TEXT method\n        TEXT status\n        INTEGER amount\n    }\n    fulfillments {\n        INTEGER id PK\n        INTEGER order_id FK\n        TEXT status\n        TEXT tracking_number\n    }\n```\n\n### Epics 7-8 - Analytics, Apps, and Webhooks\n\n```mermaid\nerDiagram\n    stores ||--o{ analytics_events : \"records\"\n    stores ||--o{ analytics_daily : \"aggregates\"\n    customers ||--o{ analytics_events : \"generates\"\n    apps ||--o{ app_installations : \"installed via\"\n    stores ||--o{ app_installations : \"has\"\n    apps ||--o{ oauth_clients : \"authenticates via\"\n    app_installations ||--o{ oauth_tokens : \"issues\"\n    stores ||--o{ webhook_subscriptions : \"registers\"\n    app_installations ||--o{ webhook_subscriptions : \"owns\"\n    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n\n    analytics_events {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT type\n        TEXT session_id\n    }\n    analytics_daily {\n        INTEGER store_id PK\n        TEXT date PK\n        INTEGER orders_count\n        INTEGER revenue_amount\n    }\n    apps {\n        INTEGER id PK\n        TEXT name\n        TEXT status\n    }\n    app_installations {\n        INTEGER id PK\n        INTEGER store_id FK\n        INTEGER app_id FK\n        TEXT status\n    }\n    webhook_subscriptions {\n        INTEGER id PK\n        INTEGER store_id FK\n        TEXT event_type\n        TEXT target_url\n    }\n    webhook_deliveries {\n        INTEGER id PK\n        INTEGER subscription_id FK\n        TEXT event_id\n        TEXT status\n    }\n```\n\n---\n\n## SQLite-Specific Configuration Requirements\n\nThe 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\n### Required PRAGMA Settings\n\n| Pragma | Value | Purpose |\n|---|---|---|\n| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n\n### Laravel Configuration Requirements\n\n- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n\n---\n\n## Migration Dependency Graph\n\nMigrations 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\n```mermaid\nflowchart TD\n    subgraph \"Batch 1 - No Dependencies\"\n        organizations\n        apps\n    end\n\n    subgraph \"Batch 2 - Depends on Batch 1\"\n        stores\n        users\n    end\n\n    subgraph \"Batch 3 - Depends on Batch 2\"\n        store_domains\n        store_users\n        store_settings\n        customers\n        themes\n        pages\n        navigation_menus\n        search_settings\n        shipping_zones\n        tax_settings\n        discounts\n        products\n        collections\n        app_installations\n        oauth_clients\n    end\n\n    subgraph \"Batch 4 - Depends on Batch 3\"\n        product_options\n        product_variants\n        product_media\n        collection_products\n        customer_addresses\n        carts\n        navigation_items\n        theme_files\n        theme_settings\n        search_queries\n        shipping_rates\n        oauth_tokens\n        webhook_subscriptions\n    end\n\n    subgraph \"Batch 5 - Depends on Batch 4\"\n        product_option_values\n        inventory_items\n        cart_lines\n        checkouts\n        orders\n        analytics_events\n        analytics_daily\n        webhook_deliveries\n    end\n\n    subgraph \"Batch 6 - Depends on Batch 5\"\n        variant_option_values\n        order_lines\n        payments\n        fulfillments\n    end\n\n    subgraph \"Batch 7 - Depends on Batch 6\"\n        refunds\n        fulfillment_lines\n    end\n\n    organizations --> stores\n    apps --> app_installations\n    apps --> oauth_clients\n    stores --> store_domains\n    stores --> store_users\n    users --> store_users\n    stores --> store_settings\n    stores --> customers\n    stores --> themes\n    stores --> pages\n    stores --> navigation_menus\n    stores --> search_settings\n    stores --> shipping_zones\n    stores --> tax_settings\n    stores --> discounts\n    stores --> products\n    stores --> collections\n    stores --> app_installations\n    products --> product_options\n    products --> product_variants\n    products --> product_media\n    collections --> collection_products\n    products --> collection_products\n    customers --> customer_addresses\n    stores --> carts\n    customers --> carts\n    navigation_menus --> navigation_items\n    themes --> theme_files\n    themes --> theme_settings\n    stores --> search_queries\n    shipping_zones --> shipping_rates\n    app_installations --> oauth_tokens\n    stores --> webhook_subscriptions\n    app_installations --> webhook_subscriptions\n    product_options --> product_option_values\n    stores --> inventory_items\n    product_variants --> inventory_items\n    carts --> cart_lines\n    product_variants --> cart_lines\n    stores --> checkouts\n    carts --> checkouts\n    customers --> checkouts\n    stores --> orders\n    customers --> orders\n    stores --> analytics_events\n    customers --> analytics_events\n    stores --> analytics_daily\n    webhook_subscriptions --> webhook_deliveries\n    product_variants --> variant_option_values\n    product_option_values --> variant_option_values\n    orders --> order_lines\n    products --> order_lines\n    product_variants --> order_lines\n    orders --> payments\n    orders --> fulfillments\n    orders --> refunds\n    payments --> refunds\n    fulfillments --> fulfillment_lines\n    order_lines --> fulfillment_lines\n```\n\n### Batch Details\n\n- **Batch 1** (no dependencies): `organizations`, `apps`\n- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n- **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- **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- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n\n---\n\n## Index Strategy Rationale\n\n1. **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\n2. **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\n3. **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\n4. **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\n5. **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\n6. **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\n---\n\n## Table Count Summary\n\n| Epic | Tables | Count |\n|---|---|---|\n| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n| 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| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n| 4 - Search | search_settings, search_queries | 2 |\n| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n| 7 - Analytics | analytics_events, analytics_daily | 2 |\n| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n| **Total application tables** | | **46** |\n| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n| **Grand total** | | **55** |\n","structuredPatch":[{"oldStart":826,"oldLines":7,"newStart":826,"newLines":7,"lines":[" | provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |"," | method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |"," | provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |","-| status | TEXT | No | 'requires_action' | CHECK IN ('requires_action', 'authorized', 'captured', 'failed', 'refunded') | |","+| status | TEXT | No | 'pending' | CHECK IN ('pending', 'captured', 'failed', 'refunded') | |"," | amount | INTEGER | No | 0 | - | Payment amount in minor units |"," | currency | TEXT | No | 'USD' | - | ISO 4217 currency code |"," | raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"fb974e2d-dc37-4dd0-8366-72d2e3bc1503"}
{"parentUuid":"1b50c770-be31-4f16-899a-c69561e2ffb8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MPxZU9TQtEovHWpuBYYfRn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Q8tXU7bHoSQenFmKyoMTSb","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`","new_string":"**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":920,"cache_read_input_tokens":58900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":920},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YK7k1KEjqbPs4pvpEB","type":"assistant","uuid":"bb935f72-9f5c-442b-a62c-711ae950cb3c","timestamp":"2026-02-11T15:45:34.143Z"}
{"parentUuid":"bb935f72-9f5c-442b-a62c-711ae950cb3c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Q8tXU7bHoSQenFmKyoMTSb","toolUseID":"toolu_01Q8tXU7bHoSQenFmKyoMTSb","timestamp":"2026-02-11T15:45:34.148Z","uuid":"81a0788e-1fe1-4631-b584-56aac2125cd1"}
{"parentUuid":"bb935f72-9f5c-442b-a62c-711ae950cb3c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Q8tXU7bHoSQenFmKyoMTSb","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"5a36b0cb-ecc1-444e-a9a5-37d0b4c62cf6","timestamp":"2026-02-11T15:45:34.147Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`","newString":"**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":412,"oldLines":7,"newStart":412,"newLines":7,"lines":[" "," ### 6.1 States"," ","-**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`","+**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`"," "," ```mermaid"," stateDiagram-v2"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"bb935f72-9f5c-442b-a62c-711ae950cb3c"}
{"parentUuid":"5a36b0cb-ecc1-444e-a9a5-37d0b4c62cf6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MPxZU9TQtEovHWpuBYYfRn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VBAjPiLSQWU8MknSydLJ8i","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)","new_string":"    shipping_selected --> payment_selected : Payment method selected\n    payment_selected --> completed : Payment confirmed\n    payment_selected --> expired : Timeout (24h)"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":920,"cache_read_input_tokens":58900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":920},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YK7k1KEjqbPs4pvpEB","type":"assistant","uuid":"53fc56a3-46a5-47b5-922f-191ae4edd17c","timestamp":"2026-02-11T15:45:35.635Z"}
{"parentUuid":"53fc56a3-46a5-47b5-922f-191ae4edd17c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01VBAjPiLSQWU8MknSydLJ8i","toolUseID":"toolu_01VBAjPiLSQWU8MknSydLJ8i","timestamp":"2026-02-11T15:45:35.640Z","uuid":"a0090849-0a28-4eba-86da-dda679028033"}
{"parentUuid":"53fc56a3-46a5-47b5-922f-191ae4edd17c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VBAjPiLSQWU8MknSydLJ8i","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"0e00dbc4-4a54-4219-a0fd-44ecb6c24eaa","timestamp":"2026-02-11T15:45:35.639Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)","newString":"    shipping_selected --> payment_selected : Payment method selected\n    payment_selected --> completed : Payment confirmed\n    payment_selected --> expired : Timeout (24h)","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_pending : Payment initiated\n    payment_pending --> completed : Payment confirmed\n    payment_pending --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":419,"oldLines":9,"newStart":419,"newLines":9,"lines":["     [*] --> started : Checkout created from cart","     started --> addressed : Address provided","     addressed --> shipping_selected : Shipping method chosen","-    shipping_selected --> payment_pending : Payment initiated","-    payment_pending --> completed : Payment confirmed","-    payment_pending --> expired : Timeout (24h)","+    shipping_selected --> payment_selected : Payment method selected","+    payment_selected --> completed : Payment confirmed","+    payment_selected --> expired : Timeout (24h)"," ","     started --> expired : Timeout (24h)","     addressed --> expired : Timeout (24h)"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"53fc56a3-46a5-47b5-922f-191ae4edd17c"}
{"parentUuid":"0e00dbc4-4a54-4219-a0fd-44ecb6c24eaa","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MPxZU9TQtEovHWpuBYYfRn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Bwk9x94678r46DnAucttmu","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed","new_string":"#### Transition: shipping_selected -> payment_selected\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_selected`.\n\n#### Transition: payment_selected -> completed"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":920,"cache_read_input_tokens":58900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":920},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YK7k1KEjqbPs4pvpEB","type":"assistant","uuid":"983f9a29-26b4-40c8-9279-33166cf67bf6","timestamp":"2026-02-11T15:45:38.957Z"}
{"parentUuid":"983f9a29-26b4-40c8-9279-33166cf67bf6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Bwk9x94678r46DnAucttmu","toolUseID":"toolu_01Bwk9x94678r46DnAucttmu","timestamp":"2026-02-11T15:45:38.965Z","uuid":"364df081-4d7e-4d7d-93fb-7f7eb52efb60"}
{"parentUuid":"983f9a29-26b4-40c8-9279-33166cf67bf6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Bwk9x94678r46DnAucttmu","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"38ffa47b-fc48-4e1a-b83f-0e5b0cb3c56b","timestamp":"2026-02-11T15:45:38.964Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed","newString":"#### Transition: shipping_selected -> payment_selected\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_selected`.\n\n#### Transition: payment_selected -> completed","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_selected : Payment method selected\n    payment_selected --> completed : Payment confirmed\n    payment_selected --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_pending\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_pending`.\n\n#### Transition: payment_pending -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":484,"oldLines":7,"newStart":484,"newLines":7,"lines":[" 5. Transition status to `shipping_selected`."," 6. Dispatch `CheckoutShippingSelected` event."," ","-#### Transition: shipping_selected -> payment_pending","+#### Transition: shipping_selected -> payment_selected"," "," **Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method"," "]},{"oldStart":497,"oldLines":9,"newStart":497,"newLines":9,"lines":[" 1. Store `payment_method` on the checkout record."," 2. Reserve inventory for all cart lines (call InventoryService.reserve for each line)."," 3. Set `expires_at` on the checkout to current time + 24 hours.","-4. Transition status to `payment_pending`.","+4. Transition status to `payment_selected`."," ","-#### Transition: payment_pending -> completed","+#### Transition: payment_selected -> completed"," "," **Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"983f9a29-26b4-40c8-9279-33166cf67bf6"}
{"parentUuid":"38ffa47b-fc48-4e1a-b83f-0e5b0cb3c56b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MPxZU9TQtEovHWpuBYYfRn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Az9zZab5mH5nbmKtDrD11f","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |","new_string":"| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |\n| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":920,"cache_read_input_tokens":58900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":920},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YK7k1KEjqbPs4pvpEB","type":"assistant","uuid":"cfb82a0e-d5bd-44c7-b14e-c42b0edb6749","timestamp":"2026-02-11T15:45:41.242Z"}
{"parentUuid":"cfb82a0e-d5bd-44c7-b14e-c42b0edb6749","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01Az9zZab5mH5nbmKtDrD11f","toolUseID":"toolu_01Az9zZab5mH5nbmKtDrD11f","timestamp":"2026-02-11T15:45:41.251Z","uuid":"7b737ba1-8c22-4dcc-a9ce-cc9a6844f363"}
{"parentUuid":"cfb82a0e-d5bd-44c7-b14e-c42b0edb6749","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Az9zZab5mH5nbmKtDrD11f","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"c273bb8c-0b09-484c-b629-029a5482cc89","timestamp":"2026-02-11T15:45:41.251Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |","newString":"| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |\n| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |\n| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |\n| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |\n| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |\n| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":572,"oldLines":8,"newStart":572,"newLines":8,"lines":[" |------------|--------|-------------|"," | `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |"," | `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |","-| `shipping_selected` -> `payment_pending` | `beginPayment(Checkout)` | All data valid, reserve inventory |","-| `payment_pending` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |","+| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |","+| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |"," | Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |"," "," **Files to create:**"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"cfb82a0e-d5bd-44c7-b14e-c42b0edb6749"}
{"parentUuid":"c273bb8c-0b09-484c-b629-029a5482cc89","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01MPxZU9TQtEovHWpuBYYfRn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CgZmj68VPEreVpTNeqPdqj","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |","new_string":"| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |\n| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":920,"cache_read_input_tokens":58900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":920},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YK7k1KEjqbPs4pvpEB","type":"assistant","uuid":"c404da0a-dc14-4e04-bb39-8e5b5c719f2c","timestamp":"2026-02-11T15:45:43.751Z"}
{"parentUuid":"c404da0a-dc14-4e04-bb39-8e5b5c719f2c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01CgZmj68VPEreVpTNeqPdqj","toolUseID":"toolu_01CgZmj68VPEreVpTNeqPdqj","timestamp":"2026-02-11T15:45:43.756Z","uuid":"3a00ced6-3693-44d5-880b-53529bbe6e20"}
{"parentUuid":"c404da0a-dc14-4e04-bb39-8e5b5c719f2c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CgZmj68VPEreVpTNeqPdqj","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"7b3130c0-c892-4510-8feb-fc39cff14e1f","timestamp":"2026-02-11T15:45:43.755Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |","newString":"| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |\n| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |\n| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |\n| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |\n| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |\n| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |\n| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":1330,"oldLines":8,"newStart":1330,"newLines":8,"lines":[" | transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |"," | rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |"," | skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |","-| transitions from shipping_selected to payment_pending | begin payment | status = payment_pending, expires_at set, inventory reserved | happy path |","-| transitions from payment_pending to completed | simulate successful payment | status = completed, order created | happy path |","+| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |","+| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |"," | rejects invalid state transitions | attempt started -> completed | exception thrown | validation |"," | recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"c404da0a-dc14-4e04-bb39-8e5b5c719f2c"}
{"parentUuid":"7b3130c0-c892-4510-8feb-fc39cff14e1f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WSvzyCSJuW7sVdAZcfdu3f","type":"message","role":"assistant","content":[{"type":"text","text":"Findings #1 and #2 fixed. Now **Finding #3 (High):** Add payment endpoints to storefront API test matrix in spec 09, **Finding #4 (High):** Fix pay endpoint error text in spec 02, **Finding #5 (High):** Add CancelUnpaidBankTransferOrders to schedule in spec 09, **Finding #6 (Medium):** Remove orphan `/payments*` mapping in spec 02."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2298,"cache_read_input_tokens":59820,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2298},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YLXdiDggGVc1Bqw3Je","type":"assistant","uuid":"ec0a043a-b6dc-46a0-9fae-7c8b556c9835","timestamp":"2026-02-11T15:45:48.490Z"}
{"parentUuid":"ec0a043a-b6dc-46a0-9fae-7c8b556c9835","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WSvzyCSJuW7sVdAZcfdu3f","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017zWNZ391tePscvPUZ33GMc","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |","new_string":"| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| selects a payment method | PUT /api/storefront/v1/checkouts/{id}/payment-method with method=credit_card | 200 response, status=payment_selected, payment_method stored | happy path |\n| completes checkout with credit card payment | POST /api/storefront/v1/checkouts/{id}/pay with card fields | 200 response, status=completed, order created | happy path |\n| rejects payment with declined card | POST /api/storefront/v1/checkouts/{id}/pay with magic decline number | 422 response, payment declined message | validation |\n| validates required address fields | PUT address with missing city | 422 response | validation |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2298,"cache_read_input_tokens":59820,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2298},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YLXdiDggGVc1Bqw3Je","type":"assistant","uuid":"c370b76d-2626-4bd8-978c-0f737e61a0fe","timestamp":"2026-02-11T15:45:54.026Z"}
{"parentUuid":"c370b76d-2626-4bd8-978c-0f737e61a0fe","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_017zWNZ391tePscvPUZ33GMc","toolUseID":"toolu_017zWNZ391tePscvPUZ33GMc","timestamp":"2026-02-11T15:45:54.031Z","uuid":"cb71edd3-7270-4523-a8cb-e6e336acf80a"}
{"parentUuid":"c370b76d-2626-4bd8-978c-0f737e61a0fe","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017zWNZ391tePscvPUZ33GMc","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"aa247e2d-0785-47b7-aa1c-9fdc1abfca23","timestamp":"2026-02-11T15:45:54.031Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |","newString":"| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| selects a payment method | PUT /api/storefront/v1/checkouts/{id}/payment-method with method=credit_card | 200 response, status=payment_selected, payment_method stored | happy path |\n| completes checkout with credit card payment | POST /api/storefront/v1/checkouts/{id}/pay with card fields | 200 response, status=completed, order created | happy path |\n| rejects payment with declined card | POST /api/storefront/v1/checkouts/{id}/pay with magic decline number | 422 response, payment declined message | validation |\n| validates required address fields | PUT address with missing city | 422 response | validation |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |\n| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |\n| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |\n| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |\n| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |\n| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":1654,"oldLines":6,"newStart":1654,"newLines":9,"lines":[" | selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |"," | applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |"," | retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |","+| selects a payment method | PUT /api/storefront/v1/checkouts/{id}/payment-method with method=credit_card | 200 response, status=payment_selected, payment_method stored | happy path |","+| completes checkout with credit card payment | POST /api/storefront/v1/checkouts/{id}/pay with card fields | 200 response, status=completed, order created | happy path |","+| rejects payment with declined card | POST /api/storefront/v1/checkouts/{id}/pay with magic decline number | 422 response, payment declined message | validation |"," | validates required address fields | PUT address with missing city | 422 response | validation |"," "," ---"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"c370b76d-2626-4bd8-978c-0f737e61a0fe"}
{"parentUuid":"aa247e2d-0785-47b7-aa1c-9fdc1abfca23","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WSvzyCSJuW7sVdAZcfdu3f","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KPJY9xBHvH14yUhPeiDj5L","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)","new_string":"- `409` - Checkout not in a valid state for payment (must be `payment_selected`)"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2298,"cache_read_input_tokens":59820,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2298},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YLXdiDggGVc1Bqw3Je","type":"assistant","uuid":"6d73dbee-ed9e-487f-b395-febffd71ae18","timestamp":"2026-02-11T15:45:55.646Z"}
{"parentUuid":"6d73dbee-ed9e-487f-b395-febffd71ae18","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01KPJY9xBHvH14yUhPeiDj5L","toolUseID":"toolu_01KPJY9xBHvH14yUhPeiDj5L","timestamp":"2026-02-11T15:45:55.650Z","uuid":"146acfa1-a181-43af-aed5-54be348b9c9d"}
{"parentUuid":"6d73dbee-ed9e-487f-b395-febffd71ae18","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KPJY9xBHvH14yUhPeiDj5L","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"dadcbb21-e39e-466c-9c1f-4e33f5dbed4f","timestamp":"2026-02-11T15:45:55.650Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)","newString":"- `409` - Checkout not in a valid state for payment (must be `payment_selected`)","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method\n\nSelect a payment method for the checkout. This step records the chosen method without processing payment.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"payment_method\": \"credit_card\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `payment_selected`.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"payment_selected\",\n  \"payment_method\": \"credit_card\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid payment method or checkout not in `shipping_selected` state\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| *(no inbound webhook endpoints - mock PSP)* | - |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)\n    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)\n    payment_selected --> shipping_selected : Payment declined, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_selected --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |\n| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |\n| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":933,"oldLines":7,"newStart":933,"newLines":7,"lines":[" "," **Error responses:**"," - `404` - Checkout not found","-- `409` - Checkout not in a valid state for payment (must be `shipping_selected` or `payment_pending`)","+- `409` - Checkout not in a valid state for payment (must be `payment_selected`)"," - `422` - Validation errors (missing card fields, invalid payment method)"," - `422` - Payment declined (credit card magic numbers)"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"6d73dbee-ed9e-487f-b395-febffd71ae18"}
{"parentUuid":"dadcbb21-e39e-466c-9c1f-4e33f5dbed4f","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WSvzyCSJuW7sVdAZcfdu3f","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01B94qxYeRVKM4Qk31TjhUnM","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |","new_string":"| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily, `CancelUnpaidBankTransferOrders` daily |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2298,"cache_read_input_tokens":59820,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2298},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YLXdiDggGVc1Bqw3Je","type":"assistant","uuid":"f768dc68-1ae7-4256-83d2-252e7ee0fcce","timestamp":"2026-02-11T15:45:58.306Z"}
{"parentUuid":"f768dc68-1ae7-4256-83d2-252e7ee0fcce","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01B94qxYeRVKM4Qk31TjhUnM","toolUseID":"toolu_01B94qxYeRVKM4Qk31TjhUnM","timestamp":"2026-02-11T15:45:58.314Z","uuid":"58ae0b32-7b2b-4e1d-b461-5c04c672a713"}
{"parentUuid":"f768dc68-1ae7-4256-83d2-252e7ee0fcce","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01B94qxYeRVKM4Qk31TjhUnM","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"618ab03d-4281-4a04-9eac-59633e47f4b0","timestamp":"2026-02-11T15:45:58.314Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |","newString":"| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily, `CancelUnpaidBankTransferOrders` daily |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |\n| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |\n| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |\n| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |\n| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |\n| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| selects a payment method | PUT /api/storefront/v1/checkouts/{id}/payment-method with method=credit_card | 200 response, status=payment_selected, payment_method stored | happy path |\n| completes checkout with credit card payment | POST /api/storefront/v1/checkouts/{id}/pay with card fields | 200 response, status=completed, order created | happy path |\n| rejects payment with declined card | POST /api/storefront/v1/checkouts/{id}/pay with magic decline number | 422 response, payment declined message | validation |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":1950,"oldLines":7,"newStart":1950,"newLines":7,"lines":[" |------|----------|"," | `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |"," | `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |","-| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily |","+| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily, `CancelUnpaidBankTransferOrders` daily |"," "," ### Service Providers"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"f768dc68-1ae7-4256-83d2-252e7ee0fcce"}
{"parentUuid":"618ab03d-4281-4a04-9eac-59633e47f4b0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01WSvzyCSJuW7sVdAZcfdu3f","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JNv7cj6t6dqCZS6jGyomQP","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"| `POST /api/storefront/v1/payments*` | `checkout` |","new_string":"| `POST /api/storefront/v1/checkouts/*/pay` | `checkout` |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2298,"cache_read_input_tokens":59820,"output_tokens":1056,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":2298,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2YLXdiDggGVc1Bqw3Je","type":"assistant","uuid":"6c96fa01-b931-46d2-96ca-ee7b05889b59","timestamp":"2026-02-11T15:45:59.782Z"}
{"parentUuid":"6c96fa01-b931-46d2-96ca-ee7b05889b59","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01JNv7cj6t6dqCZS6jGyomQP","toolUseID":"toolu_01JNv7cj6t6dqCZS6jGyomQP","timestamp":"2026-02-11T15:45:59.792Z","uuid":"893b46d4-d8f3-49e9-805e-d7623dc4b5c4"}
{"parentUuid":"6c96fa01-b931-46d2-96ca-ee7b05889b59","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JNv7cj6t6dqCZS6jGyomQP","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"aae596b5-3ff8-4b22-9de7-574b8d226d9b","timestamp":"2026-02-11T15:45:59.791Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"| `POST /api/storefront/v1/payments*` | `checkout` |","newString":"| `POST /api/storefront/v1/checkouts/*/pay` | `checkout` |","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method\n\nSelect a payment method for the checkout. This step records the chosen method without processing payment.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"payment_method\": \"credit_card\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `payment_selected`.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"payment_selected\",\n  \"payment_method\": \"credit_card\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid payment method or checkout not in `shipping_selected` state\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `payment_selected`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/payments*` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| *(no inbound webhook endpoints - mock PSP)* | - |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)\n    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)\n    payment_selected --> shipping_selected : Payment declined, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_selected --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |\n| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |\n| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":3118,"oldLines":7,"newStart":3118,"newLines":7,"lines":[" | `GET /api/storefront/v1/checkouts*` | `checkout` |"," | `PUT /api/storefront/v1/checkouts*` | `checkout` |"," | `DELETE /api/storefront/v1/checkouts*` | `checkout` |","-| `POST /api/storefront/v1/payments*` | `checkout` |","+| `POST /api/storefront/v1/checkouts/*/pay` | `checkout` |"," | `GET /api/storefront/v1/orders*` | `api.storefront` |"," | `GET /api/storefront/v1/search` | `search` |"," | `GET /api/storefront/v1/search/suggest` | `search` |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"6c96fa01-b931-46d2-96ca-ee7b05889b59"}
{"parentUuid":"aae596b5-3ff8-4b22-9de7-574b8d226d9b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Pv2M21HFpqL328BhpPMJjw","type":"message","role":"assistant","content":[{"type":"text","text":"Findings #3-#6 fixed. Now **Finding #7 (Medium):** Reconcile settings tabs across specs 02, 03, 08. The admin UI spec (03) lists: General, Domains, Shipping, Taxes, Checkout, Notifications. The API routes (02) lists: General, Domains, Localization, Checkout, Notifications, API tokens. The E2E spec (08) lists: General, Domains, Shipping, Taxes, Checkout, Notifications.\n\nThe admin UI spec (03) is the most authoritative for tab structure. Shipping and Taxes have their own separate routes (`/admin/settings/shipping`, `/admin/settings/taxes`), so they are not tabs on the main settings page. \"Localization\" and \"API tokens\" from spec 02 need to be reconciled. Let me check what the admin UI spec says more closely and align spec 02 to match."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1289,"cache_read_input_tokens":62118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1289},"output_tokens":5,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YMi4oQnW2fUZv6RxFb","type":"assistant","uuid":"bf612c19-2837-435c-814a-6e493b55cfc1","timestamp":"2026-02-11T15:46:07.008Z"}
{"parentUuid":"bf612c19-2837-435c-814a-6e493b55cfc1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Pv2M21HFpqL328BhpPMJjw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019PKNpvmjAivuvDvgetiazS","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","offset":1390,"limit":40}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1289,"cache_read_input_tokens":62118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1289},"output_tokens":5,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YMi4oQnW2fUZv6RxFb","type":"assistant","uuid":"c52da118-6786-4309-ad8f-10425a94d71a","timestamp":"2026-02-11T15:46:07.828Z"}
{"parentUuid":"c52da118-6786-4309-ad8f-10425a94d71a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_019PKNpvmjAivuvDvgetiazS","toolUseID":"toolu_019PKNpvmjAivuvDvgetiazS","timestamp":"2026-02-11T15:46:07.832Z","uuid":"de736f1c-2419-4ffa-b3cf-d7f43772de2a"}
{"parentUuid":"c52da118-6786-4309-ad8f-10425a94d71a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019PKNpvmjAivuvDvgetiazS","type":"tool_result","content":"  1390→| Property | Type | Purpose |\n  1391→|----------|------|---------|\n  1392→| `storeName` | `string` | Store display name |\n  1393→| `storeHandle` | `string` | Store handle (read-only or with warning) |\n  1394→| `defaultCurrency` | `string` | Currency code (e.g., \"EUR\") |\n  1395→| `defaultLocale` | `string` | Locale code (e.g., \"en\") |\n  1396→| `timezone` | `string` | Timezone string |\n  1397→\n  1398→| Action | Behavior |\n  1399→|--------|----------|\n  1400→| `save()` | Validates and saves store settings |\n  1401→\n  1402→#### Layout\n  1403→\n  1404→Two-column layout per section: left side has section label and description, right side has form fields.\n  1405→\n  1406→**Store details section:**\n  1407→- Left: Heading \"Store details\" + description \"Basic information about your store.\"\n  1408→- Right: Store name input, Store handle input (disabled, with description: \"The store handle cannot be changed after creation.\")\n  1409→\n  1410→Separator between sections.\n  1411→\n  1412→**Defaults section:**\n  1413→- Left: Heading \"Defaults\" + description \"Currency, language, and timezone settings.\"\n  1414→- Right:\n  1415→  - Default currency: Select (EUR/USD/GBP and more)\n  1416→  - Default locale: Select (English/German/French and more)\n  1417→  - Timezone: Select (populated from PHP timezone list)\n  1418→\n  1419→Save button at the bottom (primary variant, right-aligned).\n  1420→\n  1421→### 11.2 Domains Settings\n  1422→\n  1423→Domains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.\n  1424→\n  1425→**Livewire component:** `App\\Livewire\\Admin\\Settings\\Domains`\n  1426→\n  1427→**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`\n  1428→\n  1429→#### Component Requirements"}]},"uuid":"71dd605f-585c-4558-a709-742ec8126d92","timestamp":"2026-02-11T15:46:07.831Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/03-ADMIN-UI.md","content":"| Property | Type | Purpose |\n|----------|------|---------|\n| `storeName` | `string` | Store display name |\n| `storeHandle` | `string` | Store handle (read-only or with warning) |\n| `defaultCurrency` | `string` | Currency code (e.g., \"EUR\") |\n| `defaultLocale` | `string` | Locale code (e.g., \"en\") |\n| `timezone` | `string` | Timezone string |\n\n| Action | Behavior |\n|--------|----------|\n| `save()` | Validates and saves store settings |\n\n#### Layout\n\nTwo-column layout per section: left side has section label and description, right side has form fields.\n\n**Store details section:**\n- Left: Heading \"Store details\" + description \"Basic information about your store.\"\n- Right: Store name input, Store handle input (disabled, with description: \"The store handle cannot be changed after creation.\")\n\nSeparator between sections.\n\n**Defaults section:**\n- Left: Heading \"Defaults\" + description \"Currency, language, and timezone settings.\"\n- Right:\n  - Default currency: Select (EUR/USD/GBP and more)\n  - Default locale: Select (English/German/French and more)\n  - Timezone: Select (populated from PHP timezone list)\n\nSave button at the bottom (primary variant, right-aligned).\n\n### 11.2 Domains Settings\n\nDomains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.\n\n**Livewire component:** `App\\Livewire\\Admin\\Settings\\Domains`\n\n**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`\n\n#### Component Requirements","numLines":40,"startLine":1390,"totalLines":2429}},"sourceToolAssistantUUID":"c52da118-6786-4309-ad8f-10425a94d71a"}
{"parentUuid":"71dd605f-585c-4558-a709-742ec8126d92","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Pv2M21HFpqL328BhpPMJjw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NurDjM2kavK4XjLV21KYs5","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":1041,"limit":30}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1289,"cache_read_input_tokens":62118,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1289},"output_tokens":5,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YMi4oQnW2fUZv6RxFb","type":"assistant","uuid":"25c6ef99-c58a-4508-9c8e-cc6eb3f403ca","timestamp":"2026-02-11T15:46:08.765Z"}
{"parentUuid":"25c6ef99-c58a-4508-9c8e-cc6eb3f403ca","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01NurDjM2kavK4XjLV21KYs5","toolUseID":"toolu_01NurDjM2kavK4XjLV21KYs5","timestamp":"2026-02-11T15:46:08.768Z","uuid":"c3842efc-c7a4-4ad7-bfb9-1997ea82f5f1"}
{"parentUuid":"25c6ef99-c58a-4508-9c8e-cc6eb3f403ca","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NurDjM2kavK4XjLV21KYs5","type":"tool_result","content":"  1041→**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n  1042→\n  1043→### Settings Page Structure\n  1044→\n  1045→The admin settings page at `/admin/settings` uses a tabbed interface:\n  1046→\n  1047→| Tab | Route | Content |\n  1048→|-----|-------|---------|\n  1049→| General | `/admin/settings` | Store name, contact email, currency, locale |\n  1050→| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n  1051→| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n  1052→| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n  1053→| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n  1054→| Notifications | `/admin/settings/notifications` | Email notification preferences |\n  1055→\n  1056→> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n  1057→\n  1058→---\n  1059→\n  1060→### Test 6.1: Can view store settings\n  1061→\n  1062→**Preconditions:** Logged in as admin, Store 1 exists\n  1063→\n  1064→**Steps:**\n  1065→1. Navigate to `/admin/login`\n  1066→2. Log in as admin\n  1067→3. Click \"Settings\" in the sidebar\n  1068→\n  1069→**Expected Results:**\n  1070→- Page displays \"Store Settings\""}]},"uuid":"6abb8598-c92d-4e6f-8126-b07f3535d0d9","timestamp":"2026-02-11T15:46:08.768Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"","numLines":30,"startLine":1041,"totalLines":3174}},"sourceToolAssistantUUID":"25c6ef99-c58a-4508-9c8e-cc6eb3f403ca"}
{"parentUuid":"6abb8598-c92d-4e6f-8126-b07f3535d0d9","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GiMw3YH4waUmHwkH4vg6AY","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The admin UI spec (03) at line 1423 says tabs are: General, Domains, Shipping, Taxes, Checkout, Notifications. The E2E spec (08) matches this. The API routes (02) at line 92 says: General, Domains, Localization, Checkout, Notifications, API tokens.\n\nThe discrepancy is:\n- 02 has \"Localization\" and \"API tokens\" but not \"Shipping\" and \"Taxes\"\n- 03 and 08 have \"Shipping\", \"Taxes\" but not \"Localization\" and \"API tokens\"\n\nHowever, looking at spec 02, Shipping and Taxes have separate routes (`/admin/settings/shipping` and `/admin/settings/taxes`), so they're there. \n\nSpec 03 is the authoritative source for what tabs appear on the settings page itself, and it clearly lists Shipping and Taxes as tabs alongside the others. The API routes in spec 02 support this with their own dedicated endpoints, so there's no actual conflict—just different levels of detail in how each spec documents the feature. inline tabs on the `/admin/settings` route, while Shipping and Taxes are accessed via separate routes. So I need to update spec 02 to remove \"Localization\" and \"API tokens\" from the tab list and replace them with \"Shipping\" and \"Taxes\" to match spec 03.","signature":"EvUYCkYICxgCKkD1GmdrIBzL4jWIo806262b2SMIE8dJB4J3i5JhsogziLpTjaev3IFeAeps2X0+3//5H5bbIba03qopKxwMIM19Egzy7NCOhLl2JOQ3kEoaDIrZnK4o0f2vwPrUOiIwmcRuD2uQhvvXGhHI2stpb+QRa1zjMldAHScf2GPpmAiLrcmAth9d1YDESsxDdLGUKtwXd1fcNEZgC+iXGY9VgvvsSYN2W2DfrmmwOO3Z+cXrSPqjpLIFwQZqceU0/Vyg2hqHd9NbQc6NWNY6+dwsvx1+q8MJ2G3R9tkfvMYehRcdq8UktpK8eiipYXfc9huXgkS/39nutI3Go7WAKALPA+m/AVOz7yJCkdEXpO4NtMp//m1ucWGM/uH/FWCMp8Qgmtznef28zbUl9rx1ChqXEdlXi3rzId65qRhCjcEmvY36XfR6avMXq8v/GQUL0qYVbIyit+zHFVrarqnZa3OvlVgtL1Zr3s2s4BpI+m3wsxMxl/DTP0hceYX+2ZXCHbYXs5wer0QJUREUz/Me3W/LNPwynN1ZJOFBpuGCXxDiEbJ/saLPKetygkFMCYxAocxZLOt4qoSbtVTQXb34/gJMMYHDGJlqcrmzBZj/kg/+J2JFrYf7h0Wen6uzYsNW9NDmez6jUxrLIEnBmxeL79u5eeAgQfPDy0KBweEu/QbuuT1wd6XeqDpktrmT5RL9QhsSzjFYYDCdQWkalmqs7UxmBS+08x7tvjvB5EdxJP5OtZQAH1PC41VYaSbjRdHToWUKYtOROGeDCPJeljeqtCmdRCmfZSjYz5KX9xgLn0pSDj1OudpkdoyUNabsFcFNXW7V4tEN8dGynUeP1oNLTuwHDlB+zdDUlyGKoW7lvjWi1rDPQgTTSYRz0Ve5dtCwbrzGM2zE3DVVaPK9WQsWaA51RUW/670veGfRCt/V1VNEw6bIXvUWgV/uilSHbjhP+qI3maNFE56oMov7u1uygU2HyAbEfRKE6a/BcFKUqnarnu1i7T1crA1ylgVKyV68nXqOrgruYm9WrYxNtM1t8jBASCK+qt3MjcQYFOJXR1J6Iy1pcbr/NXuj7n97VKi8jOflvlwWE0nkGHpMCfdfbAAm74dC/EUu4kxzp3euRhDyAmTPgNGfqnNNz4VEm/uBhLZ09k2/wFmfGM90UrzCmLogEYu4ckWRr2dGw5L9OJyWaeNy+Orb73d+BhricUrTL6IQB+s7J30m5aWMXxNnw9CZXVKZF3oTB1jGaTBtcnpYSe2fz8C//lG6DZuPm6xu7OCGLo7A4w1SI7IvHKsQTIQAbmdYtJYUQqXNd6wygLyLVjXcd85BXJy22Ava+a7C2TQD+Wo0St3vTaCl8coowMVEdrzXMF/8STW3gIAuuwhwplrEs32qQConxLOyriMXskjQNf4iRSG5PvZMNKogVUK1/4iSVlD1m7lE7NVVeeJRyfzcmQC1tLqq15aeY6V7ZUCgYqwFgtffS6VKwygLwdTjP8wLHi9UmF54OBsDs6dJ7kVUA4+aKE4FVM8+C3l8kddmIcNiVqVjhbA+cb17u16fGXHwN2KLLJtOKKA/5XMFW88KvA1OjalNNJNbJZe3pg7ezkt/g7D9viO+cisqMyVYdVtC52SW+Q6R7IgWFFOxUGV2cxKv6nIvl7JDWfogqNVK2vYdbyLv5g/84cgR7HM/MkESTuBFL1lkyw0FFyiGiGv9eeeb6++57mA1AX5kyE+JxwRxCwySoRC3TD8I6jPMiXoXP4eBU4ojhEOv6gXJMFd9EEbK92yoVhEh6Dqrc1DllKWikUJUennOt1ahKWTXX8cKl548nlAaqca5TKndA0a6mtpzCewR9kVx6K4/dyzRbP9x04Q6m5r8kJFL3nJAmqqGGT8kJ18+FAmPFLlBqtlnI2MFPC49zDeRk5e99YZ/Dl7dS17v+yuJ8v3bZBA3tJwCzOb6JK1ZKGj9feZof0DwQyKpCMjhwUvqc8l8R87+wzpSTYYc74/rBc5srIzHd8WWdO+LlvCyVzZ4jkbf/gSpnSO0QvrS63k/sC9Qg1cwPyCb+Fa8NhJcBBbN7tL6RRfXBXtoc1o9dipVa7vNPEa+URuXeS5H7te9kEc6qoTyiGRgHmpD/6mjJy8WC/o5PBeAtmCs8W1owf2qU2Rr4t/MDOiXJmMn5K0nsbOy53zcciX+9nnOzPgz+q5zbZbvy0pFbgH5khvcqY52PIqCX7xnkDX/RPMtEkZKb7OsWyclRI9m2A60QUFn9joxCIZ5YZoFludTurGxhVqwezwp46QkisIw22rqueF3DfzJvGVhj6ATX4UJ7FtXI/LVOqlfUjnc6PkQhi3694+oivnVvpZtRSvX317QLb6HjAiiVEnkAg9OTne/mp9/hsq9pZ/oorAs406GAqSJYR2Bio2yZ7Ykl9x0orPLdw/G1ciS4xPyhFWr7XTlVJ5PYys/2+SwA7Hjfq1YLl6WwL142qiq2/Q4ICkFxHtCfenUGYyQuOoAan/q7s5XCXKLNU1doulIiu4bECf5VuydlKP2hUI+LExHMJItPb9HSyepENTGVjW9cZmkENisD9v3sWlbePaLfcfyv43/kwSzkS8jzZ0X8rhCTqNPs4woEXUfSo8ToHfT2mraJ3lkcxmCEdhoIumEG39hyDNXj0SWD7oY1v4OTgC7yHqlxQVysJIIFstgibxq3GGTwms0mQSRGXq9tilKu4H2CnJIe39KCXtCYAfiEsCBemdOOoumPQN749mq0GFBfU5WUDULrmPib1DNpyUZDNL45Z+eb77OSMYS6wvz/Wq4ZsaG8MsfzYwV+0Tzbs34y95jfRVSHqaRR5s+1nUEi31T6V7hCtgPxN/z4rH0GeschRP58c8ZnJtP9Ldv4XIubSNk7TfrWSZ906zz0ygddlIEokmoE9ggZbbT+gwPnOi8bu9JBHFrnpjfWUnS+UwzZU7tt4k7sFAhmCQ5jzka4MhHRRtbsZ6i8Zuk8LUDuvdK01LXGl6t19UL44bQE7R0nG+oEGL2GYMvl7V+HZlDnPPi06ejIXxYL7LJ+SjA2UApYnRPi9TjGFHD34vdMzPdUVNpdqpSviZSWEu+T+PGUa7LFAGRAkt3RBvlg5ihU9N+J0WlxCFnSunnLWX9KJ41v+K+JiSNtGwy146/btHAYVCxfWE3n4wMEbM3zlIcqFUoyHhdDzlAoXLfZfho7ac9sSiwJw5ZKs7AlXetg7yrXW9aKyeQWZBZ6dxZgVy/ZJzNsKZAovCFJd0FnkcoMz5FSjLYooO8HnuA5jjOqIy9Wkmlo60mx+How2P7hXPca+qcja1Lc5Pkjxcz8h8+j4dmaPvzxI+PBsqDd0XPv/omvGuGkZ4R3KMbIlPXu/0REr9yrFV+zrhmaeie3jdVvrER1VHOWSLshSqk+Pm7nWXKs/HQgUYQ5h6kywNy9XjYq5az/JMmt3y7pfIkxM0yH3zPW65qdOXxUj88JcHICZ7fRLrQ1RtxD7WIkQ3u/I5XKF9oJF0cFoPFZa6QyVGPg5B6qpB2W6VUVrmPOrzuTwbPr19fWOEVHrTxApBsvWMt1Bt+hkWv84SVXnLaMuDx+G/iEMb5gjfFjSyowR2xS9ENLz9vMKAgPFBhII8WSgg7SmdqYmTwDvwPAvtJn5HfSeyL8jDZiM2nSdnu5nE/70D4d4yJxo580C/eMTVS0yEoH2ArlvJ2LZ6SbPYZzm3dxLFQuS5jOlw1NQrKDkhith4QqIXPWilFkv4/qDhVxT56TT2k9aOANQjeMEYWM47Z2yO9XFyTe04vooKnSbHUg14idvQJsLd0TTh60OAhCbzIfq/O+I0ZMxJDeLTlupmpZBPSLxdhCDMYOjfQURXB9ySScb9dTdEQS6xTKF55mFizqb3ej3dKQuHTSiN4UARAweC40rmgZY7ofufFD+xkh7Cl4hizwtRgHqgcyZg9qIBv6CWi/Mhh7McvyCbABYn8v4FxSLAWU9qlqn+KNYtfJbOAnW8QPyx014ZUBbtqChzR3FCfGMv3O+G4Aq9nu27xTiBRMjt8EVRyIjojoJ8U7xbTkGHrANdk7d0rTw61numMixRcnXpsXG4wCTtJaTkOHzwNvXuQCELA8ETVRR2qa5SxkzuJdYck+GJgl6kxlfE3No5vkgWbD+m+QsOjPwxXcYqfHcKSfzF/9xoGohnrHRS4JWIRsQLZzB21CKec2ts+o+sC0jyTw2awT6O7aGOMGUScgcIAl8m3HVEaGu7fKJEaGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1525,"cache_read_input_tokens":63407,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1525},"output_tokens":13,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YNNY5LyCyP1pNxjTtu","type":"assistant","uuid":"80bcbbd9-854e-4925-9583-74b4a1b72000","timestamp":"2026-02-11T15:46:26.144Z"}
{"parentUuid":"80bcbbd9-854e-4925-9583-74b4a1b72000","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GiMw3YH4waUmHwkH4vg6AY","type":"message","role":"assistant","content":[{"type":"text","text":"The admin UI spec (03) and E2E spec (08) both agree on tabs: General, Domains, Shipping, Taxes, Checkout, Notifications. Spec 02 is the outlier with \"Localization\" and \"API tokens\" instead of \"Shipping\" and \"Taxes\". Let me align spec 02 to match."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1525,"cache_read_input_tokens":63407,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1525},"output_tokens":13,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YNNY5LyCyP1pNxjTtu","type":"assistant","uuid":"e4f23592-1969-46b9-9f1b-3f61b2df269a","timestamp":"2026-02-11T15:46:26.321Z"}
{"parentUuid":"e4f23592-1969-46b9-9f1b-3f61b2df269a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GiMw3YH4waUmHwkH4vg6AY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VcYER4JAgH4odMjAYRhFqP","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","old_string":"| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |","new_string":"| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\\Settings\\Index` |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1525,"cache_read_input_tokens":63407,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1525},"output_tokens":13,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YNNY5LyCyP1pNxjTtu","type":"assistant","uuid":"51b00178-cc98-47f6-bd1c-468bffbdb227","timestamp":"2026-02-11T15:46:28.098Z"}
{"parentUuid":"51b00178-cc98-47f6-bd1c-468bffbdb227","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01VcYER4JAgH4odMjAYRhFqP","toolUseID":"toolu_01VcYER4JAgH4odMjAYRhFqP","timestamp":"2026-02-11T15:46:28.105Z","uuid":"67a5a750-ca03-4060-9167-3e3b3926a262"}
{"parentUuid":"51b00178-cc98-47f6-bd1c-468bffbdb227","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VcYER4JAgH4odMjAYRhFqP","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md has been updated successfully."}]},"uuid":"3e088ee3-b8d7-4891-822f-804d06aa4f90","timestamp":"2026-02-11T15:46:28.105Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md","oldString":"| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |","newString":"| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\\Settings\\Index` |","originalFile":"# API and Routing Specification\n\nThis document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the Shopify-clone e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.\n\nAll monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).\n\n---\n\n## 1. Web Routes (Blade/Livewire Pages)\n\n### 1.1 Admin Auth Routes\n\nNo middleware beyond the default `web` group. These routes handle admin login and logout.\n\n| Method | Path | Description | Component / Action |\n|--------|------|-------------|--------------------|\n| GET | `/admin/login` | Admin login page | `Livewire: Admin\\Auth\\Login` |\n| POST | `/admin/login` | Process admin login | Handled by Livewire action |\n| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |\n| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\\Auth\\ForgotPassword` |\n| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |\n| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\\Auth\\ResetPassword` |\n| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |\n\nRate limit: `login` tier (5 per minute per IP).\n\n---\n\n### 1.2 Admin Routes\n\n**Prefix:** `/admin`\n**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`\n\nAll admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).\n\n> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.\n\n#### Dashboard\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\\Dashboard` |\n\n#### Products\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\\Products\\Index` |\n| GET | `/admin/products/create` | Create product form | `Admin\\Products\\Create` |\n| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\\Products\\Edit` |\n\n#### Inventory\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/inventory` | Inventory management page | `Admin\\Inventory\\Index` |\n\n#### Collections\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/collections` | Collection list | `Admin\\Collections\\Index` |\n| GET | `/admin/collections/create` | Create collection form | `Admin\\Collections\\Create` |\n| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\\Collections\\Edit` |\n\n#### Orders\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/orders` | Order list with status filters | `Admin\\Orders\\Index` |\n| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\\Orders\\Show` |\n\n#### Customers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/customers` | Customer list | `Admin\\Customers\\Index` |\n| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\\Customers\\Show` |\n\n#### Discounts\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/discounts` | Discount list | `Admin\\Discounts\\Index` |\n| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\\Discounts\\Create` |\n| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\\Discounts\\Edit` |\n\n#### Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |\n| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |\n| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |\n\n#### Themes and Online Store\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\\Themes\\Index` |\n| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\\Themes\\Editor` |\n\n#### Pages\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/pages` | Content pages list | `Admin\\Pages\\Index` |\n| GET | `/admin/pages/create` | Create content page | `Admin\\Pages\\Create` |\n| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\\Pages\\Edit` |\n\n#### Navigation\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/navigation` | Navigation menus management | `Admin\\Navigation\\Index` |\n\n#### Apps\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\\Apps\\Index` |\n| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\\Apps\\Show` |\n\n#### Developers\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\\Developers\\Index` |\n\n> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.\n\n#### Analytics\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\\Analytics\\Index` |\n\n#### Search Settings\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\\Search\\Settings` |\n\n---\n\n### 1.3 Storefront Routes\n\n**Middleware:** `web`, `store.resolve`\n\nThese routes serve the public-facing storefront using Blade templates rendered through the active theme engine.\n\n| Method | Path | Description | Component / View |\n|--------|------|-------------|------------------|\n| GET | `/` | Home page | `Storefront\\Home` |\n| GET | `/collections` | All collections listing | `Storefront\\Collections\\Index` |\n| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\\Collections\\Show` |\n| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\\Products\\Show` |\n| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\\Cart\\Show` |\n| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\\Search\\Index` |\n| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\\Pages\\Show` |\n\n#### Customer Account Routes\n\n**Additional middleware:** `auth:customer`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account` | Customer account overview | `Storefront\\Account\\Dashboard` |\n| GET | `/account/orders` | Order history list | `Storefront\\Account\\Orders\\Index` |\n| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\\Account\\Orders\\Show` |\n| GET | `/account/addresses` | Address book management | `Storefront\\Account\\Addresses\\Index` |\n\n#### Customer Auth Routes (no auth required)\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/account/login` | Customer login page | `Storefront\\Account\\Auth\\Login` |\n| GET | `/account/register` | Customer registration page | `Storefront\\Account\\Auth\\Register` |\n| GET | `/forgot-password` | Forgot password page | `Storefront\\Account\\Auth\\ForgotPassword` |\n| POST | `/forgot-password` | Send reset link | Handled by Livewire action |\n| GET | `/reset-password/{token}` | Reset password form | `Storefront\\Account\\Auth\\ResetPassword` |\n| POST | `/reset-password` | Reset password | Handled by Livewire action |\n\n---\n\n### 1.4 Checkout Routes\n\n**Middleware:** `web`, `store.resolve`\n\n| Method | Path | Description | Component |\n|--------|------|-------------|-----------|\n| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\\Checkout\\Show` |\n| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\\Checkout\\Confirmation` |\n\n---\n\n## 2. Storefront REST API\n\n**Prefix:** `/api/storefront/v1`\n**Middleware:** `store.resolve`, `throttle:api.storefront`\n**Content-Type:** `application/json`\n\nThese endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).\n\n---\n\n### 2.1 Cart Endpoints\n\n#### POST /api/storefront/v1/carts\n\nCreate a new cart.\n\n- **Authentication:** None (guest) or session-based (logged-in customer)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"currency\": \"EUR\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 1,\n  \"status\": \"active\",\n  \"lines\": [],\n  \"totals\": {\n    \"subtotal\": 0,\n    \"discount\": 0,\n    \"total\": 0,\n    \"currency\": \"EUR\"\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `422` - Validation error (invalid currency)\n\n---\n\n#### GET /api/storefront/v1/carts/{cartId}\n\nRetrieve a cart with all lines and computed totals.\n\n- **Authentication:** None (cart ID acts as token)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**URL parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `cartId` | integer | Yes | The cart identifier |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 42,\n  \"store_id\": 1,\n  \"customer_id\": null,\n  \"currency\": \"EUR\",\n  \"cart_version\": 3,\n  \"status\": \"active\",\n  \"lines\": [\n    {\n      \"id\": 201,\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_subtotal_amount\": 5000,\n      \"line_discount_amount\": 0,\n      \"line_total_amount\": 5000,\n      \"image_url\": \"https://cdn.example.com/products/tshirt-blue.jpg\",\n      \"requires_shipping\": true,\n      \"available_quantity\": 50\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\",\n    \"line_count\": 1,\n    \"item_count\": 2\n  },\n  \"created_at\": \"2026-02-11T10:00:00Z\",\n  \"updated_at\": \"2026-02-11T10:05:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n\n---\n\n#### POST /api/storefront/v1/carts/{cartId}/lines\n\nAdd a line item to the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"variant_id\": 101,\n  \"quantity\": 2\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full cart object (same schema as GET /carts/{cartId}).\n\n**Error responses:**\n- `404` - Cart not found\n- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)\n- `422` - Validation error\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"variant_id\": [\"The selected variant is out of stock.\"]\n  }\n}\n```\n\n---\n\n#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nUpdate the quantity of a cart line.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"quantity\": 3,\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |\n| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object with updated totals.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n- `422` - Validation error (e.g., exceeds inventory)\n\n---\n\n#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}\n\nRemove a line item from the cart.\n\n- **Authentication:** None\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"cart_version\": 3\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_version` | integer | Yes | Must match current cart version. |\n\n**Success response:** `200 OK`\n\nReturns the full cart object.\n\n**Error responses:**\n- `404` - Cart or line not found\n- `409` - Version conflict\n\n---\n\n### 2.2 Checkout Endpoints\n\n#### Checkout Flow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant C as Customer Browser\n    participant API as Storefront API\n    participant MockPSP as Mock Payment Provider\n\n    C->>API: POST /checkouts (cart_id, email)\n    API-->>C: 201 Checkout (status: started)\n\n    C->>API: PUT /checkouts/{id}/address\n    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)\n\n    C->>API: PUT /checkouts/{id}/shipping-method\n    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)\n\n    opt Apply discount\n        C->>API: POST /checkouts/{id}/apply-discount\n        API-->>C: 200 Checkout (totals recalculated)\n    end\n\n    C->>API: POST /checkouts/{id}/pay (payment_method + details)\n    API->>API: Reserve inventory\n    API->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>API: PaymentResult (success)\n        API->>API: Create order (paid), commit inventory\n        API-->>C: 200 (order confirmation data)\n    else Credit Card (decline)\n        MockPSP-->>API: PaymentResult (failed)\n        API->>API: Release reserved inventory\n        API-->>C: 422 (error: card_declined / insufficient_funds)\n    else Bank Transfer (deferred)\n        MockPSP-->>API: PaymentResult (pending)\n        API->>API: Create order (pending), inventory stays reserved\n        API-->>C: 200 (order confirmation + bank transfer instructions)\n    end\n```\n\n#### POST /api/storefront/v1/checkouts\n\nCreate a checkout from a cart.\n\n- **Authentication:** None or session-based\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"cart_id\": 42,\n  \"email\": \"customer@example.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |\n| `email` | string | Yes | Valid email address. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"id\": 7,\n  \"store_id\": 1,\n  \"cart_id\": 42,\n  \"customer_id\": null,\n  \"status\": \"started\",\n  \"email\": \"customer@example.com\",\n  \"shipping_address_json\": null,\n  \"billing_address_json\": null,\n  \"shipping_method_id\": null,\n  \"discount_code\": null,\n  \"lines\": [\n    {\n      \"variant_id\": 101,\n      \"product_title\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"line_total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  },\n  \"available_shipping_methods\": [],\n  \"expires_at\": \"2026-02-12T10:00:00Z\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n**Error responses:**\n- `404` - Cart not found\n- `422` - Validation errors (empty cart, invalid email)\n\n---\n\n#### GET /api/storefront/v1/checkouts/{checkoutId}\n\nRetrieve the current checkout state and computed totals.\n\n- **Authentication:** None (checkout ID acts as token)\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object (same schema as POST response, with updated fields reflecting the current state).\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/address\n\nSet the shipping and/or billing address on a checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address\": null,\n  \"use_shipping_as_billing\": true\n}\n```\n\n**Shipping address fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |\n| `shipping_address.first_name` | string | Yes | Max: 255 |\n| `shipping_address.last_name` | string | Yes | Max: 255 |\n| `shipping_address.address1` | string | Yes | Max: 500 |\n| `shipping_address.address2` | string | No | Max: 500 |\n| `shipping_address.city` | string | Yes | Max: 255 |\n| `shipping_address.province` | string | No | Max: 255 |\n| `shipping_address.province_code` | string | No | Max: 10 |\n| `shipping_address.country` | string | Yes | Max: 255 |\n| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |\n| `shipping_address.postal_code` | string | Yes | Max: 20 |\n| `shipping_address.phone` | string | No | Max: 50 |\n| `billing_address` | object | No | Same fields as shipping_address |\n| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"addressed\",\n  \"shipping_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"billing_address_json\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"address2\": \"Apt 4B\",\n    \"city\": \"Berlin\",\n    \"province\": \"Berlin\",\n    \"province_code\": \"BE\",\n    \"country\": \"DE\",\n    \"country_code\": \"DE\",\n    \"postal_code\": \"10115\",\n    \"phone\": \"+49301234567\"\n  },\n  \"available_shipping_methods\": [\n    {\n      \"id\": 1,\n      \"name\": \"Standard Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 500,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 3,\n      \"estimated_days_max\": 5\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Express Shipping\",\n      \"type\": \"flat\",\n      \"price_amount\": 1200,\n      \"currency\": \"EUR\",\n      \"estimated_days_min\": 1,\n      \"estimated_days_max\": 2\n    }\n  ],\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 0,\n    \"tax\": 0,\n    \"total\": 5000,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method\n\nSelect a shipping method for the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"shipping_method_id\": 1\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"shipping_method_id\": 1,\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"tax_provider_snapshot_json\": {\n    \"provider\": \"manual\",\n    \"calculated_at\": \"2026-02-11T10:05:00Z\",\n    \"lines\": [\n      {\n        \"variant_id\": 101,\n        \"tax_amount\": 950,\n        \"rate\": 1900,\n        \"jurisdiction\": \"DE\"\n      }\n    ],\n    \"shipping_tax_amount\": 95,\n    \"shipping_tax_rate\": 1900\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid shipping method (not available for this address/checkout)\n\n---\n\n#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method\n\nSelect a payment method for the checkout. This step records the chosen method without processing payment.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"payment_method\": \"credit_card\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n\n**Success response:** `200 OK`\n\nReturns the full checkout object. Status transitions to `payment_selected`.\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"payment_selected\",\n  \"payment_method\": \"credit_card\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 0,\n    \"shipping\": 500,\n    \"tax\": 1045,\n    \"total\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid payment method or checkout not in `shipping_selected` state\n\n---\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount\n\nApply a discount code to the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body:**\n\n```json\n{\n  \"code\": \"WELCOME10\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"id\": 7,\n  \"status\": \"shipping_selected\",\n  \"discount_code\": \"WELCOME10\",\n  \"totals\": {\n    \"subtotal\": 5000,\n    \"discount\": 500,\n    \"shipping\": 500,\n    \"tax\": 950,\n    \"total\": 5950,\n    \"currency\": \"EUR\"\n  },\n  \"applied_discounts\": [\n    {\n      \"code\": \"WELCOME10\",\n      \"type\": \"percent\",\n      \"value_amount\": 10,\n      \"applied_amount\": 500,\n      \"description\": \"10% off your first order\"\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Discount code expired (error code: `discount_expired`)\n- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)\n- `404` - Checkout not found\n- `410` - Checkout expired\n- `422` - Invalid code (not found, does not apply to these items)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n---\n\n#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount\n\nRemove the applied discount code from the checkout.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Success response:** `200 OK`\n\nReturns the full checkout object with discount removed and totals recalculated.\n\n**Error responses:**\n- `404` - Checkout not found or no discount applied\n- `410` - Checkout expired\n\n---\n\n### 2.3 Payment Endpoint\n\n#### POST /api/storefront/v1/checkouts/{checkoutId}/pay\n\nProcess payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.\n\n- **Authentication:** None\n- **Rate limit:** `checkout` (10/min per session)\n\n**Request body (Credit Card):**\n\n```json\n{\n  \"payment_method\": \"credit_card\",\n  \"card_number\": \"4242424242424242\",\n  \"card_expiry\": \"12/28\",\n  \"card_cvc\": \"123\",\n  \"card_holder\": \"Jane Doe\"\n}\n```\n\n**Request body (PayPal):**\n\n```json\n{\n  \"payment_method\": \"paypal\"\n}\n```\n\n**Request body (Bank Transfer):**\n\n```json\n{\n  \"payment_method\": \"bank_transfer\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |\n| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |\n| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |\n| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |\n| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |\n\n**Success response (Credit Card / PayPal - instant capture):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"payment_method\": \"credit_card\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  }\n}\n```\n\n**Success response (Bank Transfer - deferred payment):** `200 OK`\n\n```json\n{\n  \"checkout_id\": 7,\n  \"status\": \"completed\",\n  \"order\": {\n    \"id\": 42,\n    \"order_number\": \"#1016\",\n    \"status\": \"pending\",\n    \"financial_status\": \"pending\",\n    \"payment_method\": \"bank_transfer\",\n    \"total_amount\": 6545,\n    \"currency\": \"EUR\"\n  },\n  \"bank_transfer_instructions\": {\n    \"bank_name\": \"Mock Bank AG\",\n    \"iban\": \"DE89 3704 0044 0532 0130 00\",\n    \"bic\": \"COBADEFFXXX\",\n    \"reference\": \"#1016\",\n    \"amount_formatted\": \"65.45 EUR\"\n  }\n}\n```\n\n**Error responses:**\n- `404` - Checkout not found\n- `409` - Checkout not in a valid state for payment (must be `payment_selected`)\n- `422` - Validation errors (missing card fields, invalid payment method)\n- `422` - Payment declined (credit card magic numbers)\n\n```json\n{\n  \"message\": \"Your card was declined.\",\n  \"error_code\": \"card_declined\"\n}\n```\n\n**Magic card number error codes:**\n\n| Card Number | Error Code | Message |\n|---|---|---|\n| `4000000000000002` | `card_declined` | \"Your card was declined.\" |\n| `4000000000009995` | `insufficient_funds` | \"Your card has insufficient funds.\" |\n\n---\n\n### 2.4 Order Endpoints\n\n#### GET /api/storefront/v1/orders/{orderNumber}\n\nRetrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).\n\n- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)\n- **Rate limit:** `api.storefront` (120/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `token` | string | Yes | HMAC-signed access token for this order |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"order_number\": \"#1001\",\n  \"status\": \"paid\",\n  \"financial_status\": \"paid\",\n  \"fulfillment_status\": \"unfulfilled\",\n  \"email\": \"customer@example.com\",\n  \"currency\": \"EUR\",\n  \"placed_at\": \"2026-02-11T10:10:00Z\",\n  \"lines\": [\n    {\n      \"title_snapshot\": \"Classic T-Shirt\",\n      \"variant_title\": \"Blue / Medium\",\n      \"sku_snapshot\": \"TSH-BLU-M\",\n      \"quantity\": 2,\n      \"unit_price_amount\": 2500,\n      \"total_amount\": 5000\n    }\n  ],\n  \"totals\": {\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950\n  },\n  \"shipping_address\": {\n    \"first_name\": \"Jane\",\n    \"last_name\": \"Doe\",\n    \"address1\": \"123 Main St\",\n    \"city\": \"Berlin\",\n    \"country\": \"DE\",\n    \"postal_code\": \"10115\"\n  },\n  \"fulfillments\": []\n}\n```\n\n**Error responses:**\n- `401` - Invalid or missing token\n- `404` - Order not found\n\n---\n\n### 2.5 Search Endpoints\n\n#### GET /api/storefront/v1/search\n\nSearch products within the current store.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |\n| `filters` | json | No | URL-encoded JSON object with filter facets. |\n| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |\n\n**Filters JSON schema:**\n\n```json\n{\n  \"collection_id\": 5,\n  \"price_min\": 1000,\n  \"price_max\": 5000,\n  \"in_stock\": true,\n  \"tags\": [\"organic\", \"cotton\"],\n  \"vendor\": \"Acme Apparel\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-shirt\",\n  \"results\": [\n    {\n      \"id\": 10,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt.jpg\",\n      \"in_stock\": true,\n      \"tags\": [\"organic\", \"cotton\"]\n    }\n  ],\n  \"facets\": {\n    \"vendors\": [\n      { \"value\": \"Acme Apparel\", \"count\": 12 }\n    ],\n    \"tags\": [\n      { \"value\": \"organic\", \"count\": 8 },\n      { \"value\": \"cotton\", \"count\": 15 }\n    ],\n    \"price_range\": {\n      \"min\": 1500,\n      \"max\": 8500\n    }\n  },\n  \"pagination\": {\n    \"current_page\": 1,\n    \"per_page\": 24,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n**Error responses:**\n- `400` - Query too long (max 200 characters) or malformed filters\n- `422` - Validation error\n\n---\n\n#### GET /api/storefront/v1/search/suggest\n\nAutocomplete suggestions as the user types.\n\n- **Authentication:** None\n- **Rate limit:** `search` (30/min per IP)\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |\n| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"query\": \"t-sh\",\n  \"suggestions\": [\n    {\n      \"type\": \"product\",\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"image_url\": \"https://cdn.example.com/products/tshirt-thumb.jpg\",\n      \"price_amount\": 2500,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"type\": \"collection\",\n      \"title\": \"T-Shirts\",\n      \"handle\": \"t-shirts\",\n      \"image_url\": null\n    }\n  ]\n}\n```\n\n**Error responses:**\n- `400` - Query too long\n- `422` - Validation error\n\n---\n\n### 2.6 Analytics Endpoints\n\n#### POST /api/storefront/v1/analytics/events\n\nBatch submit storefront analytics events.\n\n- **Authentication:** None\n- **Rate limit:** `analytics` (60/min per IP)\n\n**Request body:**\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_001\",\n      \"properties\": {\n        \"url\": \"/products/classic-t-shirt\",\n        \"referrer\": \"https://google.com\",\n        \"user_agent\": \"Mozilla/5.0...\"\n      },\n      \"occurred_at\": \"2026-02-11T10:00:00Z\"\n    },\n    {\n      \"type\": \"add_to_cart\",\n      \"session_id\": \"sess_abc123\",\n      \"client_event_id\": \"evt_002\",\n      \"properties\": {\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"quantity\": 1,\n        \"price_amount\": 2500,\n        \"currency\": \"EUR\"\n      },\n      \"occurred_at\": \"2026-02-11T10:01:00Z\"\n    }\n  ]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `events` | array | Yes | Min: 1, max: 50 events per batch. |\n| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |\n| `events[].session_id` | string | Yes | Max: 100 characters. |\n| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |\n| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |\n| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"accepted\": 2,\n  \"rejected\": 0\n}\n```\n\n**Error responses:**\n- `422` - Validation errors (invalid event types, too many events, etc.)\n\n---\n\n## 3. Admin REST API\n\n**Prefix:** `/api/admin/v1`\n**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`\n**Content-Type:** `application/json`\n\nThese endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.\n\nAll endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.\n\n---\n\n### 3.1 Platform Management (Super-Admin Only)\n\nThese endpoints are restricted to users with the `owner` (platform) role.\n\n#### POST /api/admin/v1/platform/organizations\n\nCreate a new organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Acme Corp\",\n  \"billing_email\": \"billing@acme.com\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `billing_email` | string | Yes | Valid email, max: 255 |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Acme Corp\",\n    \"billing_email\": \"billing@acme.com\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"updated_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/platform/stores\n\nCreate a new store within an organization.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"organization_id\": 1,\n  \"name\": \"Acme Store\",\n  \"handle\": \"acme\",\n  \"default_currency\": \"EUR\",\n  \"default_locale\": \"en\",\n  \"timezone\": \"Europe/Berlin\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `organization_id` | integer | Yes | Must exist |\n| `name` | string | Yes | Max: 255 |\n| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |\n| `default_currency` | string | Yes | Valid ISO 4217 code |\n| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |\n| `timezone` | string | Yes | Valid IANA timezone |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"organization_id\": 1,\n    \"name\": \"Acme Store\",\n    \"handle\": \"acme\",\n    \"status\": \"active\",\n    \"default_currency\": \"EUR\",\n    \"default_locale\": \"en\",\n    \"timezone\": \"Europe/Berlin\",\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate handle, invalid timezone, etc.)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/invites\n\nInvite a user to a store with a specific role.\n\n- **Authentication:** Sanctum token with `manage-platform` scope\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Request body:**\n\n```json\n{\n  \"email\": \"staff@acme.com\",\n  \"role\": \"staff\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `email` | string | Yes | Valid email, max: 255 |\n| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"email\": \"staff@acme.com\",\n    \"role\": \"staff\",\n    \"invited_at\": \"2026-02-11T10:00:00Z\",\n    \"expires_at\": \"2026-02-18T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions (only `owner` and `admin` can invite)\n- `409` - User already a member of this store\n- `422` - Validation errors\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/me\n\nGet the current user's membership details for a store.\n\n- **Authentication:** Sanctum token\n- **Rate limit:** `api.admin` (60/min per token)\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"user_id\": 3,\n    \"store_id\": 1,\n    \"role\": \"admin\",\n    \"email\": \"admin@acme.com\",\n    \"name\": \"Jane Admin\",\n    \"permissions\": [\n      \"read-products\",\n      \"write-products\",\n      \"read-orders\",\n      \"write-orders\",\n      \"read-settings\",\n      \"write-settings\",\n      \"read-customers\"\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Not a member of this store\n- `404` - Store not found\n\n---\n\n### 3.2 Products\n\n#### Admin Products Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/products\n\nList products with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title, vendor, or SKU. |\n| `collection_id` | integer | No | Filter by collection membership. |\n| `page` | integer | No | Page number. Default: 1. |\n| `per_page` | integer | No | Results per page. Default: 25, max: 100. |\n| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 10,\n      \"store_id\": 1,\n      \"title\": \"Classic T-Shirt\",\n      \"handle\": \"classic-t-shirt\",\n      \"status\": \"active\",\n      \"vendor\": \"Acme Apparel\",\n      \"product_type\": \"Apparel\",\n      \"tags\": [\"organic\", \"cotton\"],\n      \"variants_count\": 6,\n      \"total_inventory\": 150,\n      \"published_at\": \"2026-02-10T08:00:00Z\",\n      \"created_at\": \"2026-02-09T14:00:00Z\",\n      \"updated_at\": \"2026-02-11T09:00:00Z\",\n      \"featured_image\": {\n        \"url\": \"https://cdn.example.com/products/tshirt.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\"\n      }\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 42,\n    \"last_page\": 2\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products\n\nCreate a new product.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Classic T-Shirt\",\n  \"handle\": \"classic-t-shirt\",\n  \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n  \"vendor\": \"Acme Apparel\",\n  \"product_type\": \"Apparel\",\n  \"status\": \"draft\",\n  \"tags\": [\"organic\", \"cotton\"],\n  \"options\": [\n    { \"name\": \"Color\", \"position\": 1 },\n    { \"name\": \"Size\", \"position\": 2 }\n  ],\n  \"variants\": [\n    {\n      \"sku\": \"TSH-BLU-S\",\n      \"barcode\": \"1234567890123\",\n      \"price_amount\": 2500,\n      \"compare_at_amount\": 3500,\n      \"currency\": \"EUR\",\n      \"weight_g\": 200,\n      \"requires_shipping\": true,\n      \"is_default\": true,\n      \"position\": 1,\n      \"status\": \"active\",\n      \"option_values\": [\n        { \"option_name\": \"Color\", \"value\": \"Blue\" },\n        { \"option_name\": \"Size\", \"value\": \"Small\" }\n      ],\n      \"inventory\": {\n        \"quantity_on_hand\": 50,\n        \"policy\": \"deny\"\n      }\n    }\n  ],\n  \"collections\": [5]\n}\n```\n\n**Product fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |\n| `description_html` | string | No | Max: 65535 |\n| `vendor` | string | No | Max: 255 |\n| `product_type` | string | No | Max: 255 |\n| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |\n| `tags` | array of strings | No | Max: 50 tags, each max: 255. |\n| `options` | array | No | Max: 3 options. |\n| `options[].name` | string | Yes | Max: 255 |\n| `options[].position` | integer | Yes | Min: 1, max: 3 |\n| `collections` | array of integers | No | Collection IDs to add this product to. |\n\n**Variant fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `variants` | array | Yes | At least one variant required. Max: 100. |\n| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |\n| `variants[].barcode` | string | No | Max: 255 |\n| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |\n| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |\n| `variants[].currency` | string | No | Defaults to store currency. |\n| `variants[].weight_g` | integer | No | Min: 0 |\n| `variants[].requires_shipping` | boolean | No | Default: true |\n| `variants[].is_default` | boolean | No | Exactly one variant must be default. |\n| `variants[].position` | integer | No | Auto-assigned if not provided. |\n| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |\n| `variants[].option_values` | array | No | Must match defined options. |\n| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |\n| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |\n\n**Success response:** `201 Created`\n\nReturns the full product resource with nested variants, options, and media.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/products/{productId}\n\nGet a single product with all variants, options, media, and collections.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"store_id\": 1,\n    \"title\": \"Classic T-Shirt\",\n    \"handle\": \"classic-t-shirt\",\n    \"description_html\": \"<p>A comfortable cotton t-shirt.</p>\",\n    \"vendor\": \"Acme Apparel\",\n    \"product_type\": \"Apparel\",\n    \"status\": \"active\",\n    \"tags\": [\"organic\", \"cotton\"],\n    \"published_at\": \"2026-02-10T08:00:00Z\",\n    \"created_at\": \"2026-02-09T14:00:00Z\",\n    \"updated_at\": \"2026-02-11T09:00:00Z\",\n    \"options\": [\n      {\n        \"id\": 1,\n        \"name\": \"Color\",\n        \"position\": 1,\n        \"values\": [\n          { \"id\": 1, \"value\": \"Blue\", \"position\": 1 },\n          { \"id\": 2, \"value\": \"Red\", \"position\": 2 }\n        ]\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Size\",\n        \"position\": 2,\n        \"values\": [\n          { \"id\": 3, \"value\": \"Small\", \"position\": 1 },\n          { \"id\": 4, \"value\": \"Medium\", \"position\": 2 },\n          { \"id\": 5, \"value\": \"Large\", \"position\": 3 }\n        ]\n      }\n    ],\n    \"variants\": [\n      {\n        \"id\": 101,\n        \"sku\": \"TSH-BLU-S\",\n        \"barcode\": \"1234567890123\",\n        \"price_amount\": 2500,\n        \"compare_at_amount\": 3500,\n        \"currency\": \"EUR\",\n        \"weight_g\": 200,\n        \"requires_shipping\": true,\n        \"is_default\": true,\n        \"position\": 1,\n        \"status\": \"active\",\n        \"option_values\": [\n          { \"option_name\": \"Color\", \"value\": \"Blue\" },\n          { \"option_name\": \"Size\", \"value\": \"Small\" }\n        ],\n        \"inventory\": {\n          \"quantity_on_hand\": 50,\n          \"quantity_reserved\": 2,\n          \"policy\": \"deny\"\n        }\n      }\n    ],\n    \"media\": [\n      {\n        \"id\": 1,\n        \"type\": \"image\",\n        \"storage_key\": \"stores/1/products/10/main.jpg\",\n        \"url\": \"https://cdn.example.com/stores/1/products/10/main.jpg\",\n        \"alt_text\": \"Classic T-Shirt in Blue\",\n        \"width\": 1200,\n        \"height\": 1600,\n        \"mime_type\": \"image/jpeg\",\n        \"byte_size\": 245000,\n        \"position\": 1,\n        \"status\": \"ready\"\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": 5,\n        \"title\": \"Summer Collection\",\n        \"handle\": \"summer-collection\"\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found (or belongs to another store)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/products/{productId}\n\nUpdate a product. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.\n\n**Example partial update request:**\n\n```json\n{\n  \"title\": \"Classic Cotton T-Shirt\",\n  \"status\": \"active\",\n  \"tags\": [\"organic\", \"cotton\", \"bestseller\"]\n}\n```\n\n**Success response:** `200 OK`\n\nReturns the full updated product resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}\n\nArchive a product (soft delete). Products with existing orders cannot be hard-deleted.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"archived\",\n    \"updated_at\": \"2026-02-11T11:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload\n\nGenerate a presigned URL for uploading product media directly to object storage (S3).\n\n**Request body:**\n\n```json\n{\n  \"filename\": \"tshirt-front.jpg\",\n  \"content_type\": \"image/jpeg\",\n  \"byte_size\": 245000\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `filename` | string | Yes | Max: 255. Must have a valid extension. |\n| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |\n| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"upload_url\": \"https://s3.example.com/...presigned...\",\n  \"method\": \"PUT\",\n  \"headers\": {\n    \"Content-Type\": \"image/jpeg\"\n  },\n  \"storage_key\": \"stores/1/products/10/media/uuid.jpg\",\n  \"media_id\": 2,\n  \"expires_at\": \"2026-02-11T10:10:00Z\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Product not found\n- `422` - Validation errors (invalid content type, file too large)\n\n---\n\n### 3.3 Collections\n\n#### Admin Collections Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/collections\n\nList collections.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |\n| `query` | string | No | Search by title. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"store_id\": 1,\n      \"title\": \"Summer Collection\",\n      \"handle\": \"summer-collection\",\n      \"description_html\": \"<p>Best items for summer.</p>\",\n      \"type\": \"manual\",\n      \"status\": \"active\",\n      \"products_count\": 15,\n      \"created_at\": \"2026-02-01T10:00:00Z\",\n      \"updated_at\": \"2026-02-10T14:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 5,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/collections\n\nCreate a collection.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Winter Collection\",\n  \"handle\": \"winter-collection\",\n  \"description_html\": \"<p>Cozy items for winter.</p>\",\n  \"type\": \"manual\",\n  \"status\": \"active\",\n  \"product_ids\": [10, 11]\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated from title. Unique per store. |\n| `description_html` | string | No | Max: 65535 |\n| `type` | string | Yes | One of: `manual`, `automated`. |\n| `status` | string | No | One of: `draft`, `active`, `archived`. Default: `active`. |\n| `product_ids` | array of integers | No | Products to add to the collection. |\n\n**Success response:** `201 Created`\n\nReturns the full collection resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nUpdate a collection.\n\nSame fields as POST (all optional). Supports partial updates. Use `product_ids` to replace the full product set, or `add_product_ids` / `remove_product_ids` for incremental changes.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}\n\nDelete a collection.\n\n**Authentication:** Bearer token with `write-collections` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Collection deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Collection not found\n\n---\n\n### 3.4 Orders\n\n#### Admin Orders Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/orders\n\nList orders with filtering and pagination.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |\n| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |\n| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |\n| `customer_id` | integer | No | Filter by customer. |\n| `created_after` | string (ISO 8601) | No | Orders placed after this date. |\n| `created_before` | string (ISO 8601) | No | Orders placed before this date. |\n| `query` | string | No | Search by order number or customer email. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n| `sort` | string | No | One of: `placed_at_desc`, `placed_at_asc`, `total_desc`, `total_asc`. Default: `placed_at_desc`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 50,\n      \"order_number\": \"#1001\",\n      \"status\": \"paid\",\n      \"financial_status\": \"paid\",\n      \"fulfillment_status\": \"unfulfilled\",\n      \"customer\": {\n        \"id\": 30,\n        \"name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\"\n      },\n      \"currency\": \"EUR\",\n      \"subtotal_amount\": 5000,\n      \"discount_amount\": 500,\n      \"shipping_amount\": 500,\n      \"tax_amount\": 950,\n      \"total_amount\": 5950,\n      \"line_count\": 1,\n      \"placed_at\": \"2026-02-11T10:10:00Z\",\n      \"created_at\": \"2026-02-11T10:10:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 156,\n    \"last_page\": 7\n  }\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}\n\nGet full order details.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 50,\n    \"store_id\": 1,\n    \"order_number\": \"#1001\",\n    \"status\": \"paid\",\n    \"financial_status\": \"paid\",\n    \"fulfillment_status\": \"unfulfilled\",\n    \"customer\": {\n      \"id\": 30,\n      \"name\": \"Jane Doe\",\n      \"email\": \"jane@example.com\"\n    },\n    \"email\": \"jane@example.com\",\n    \"currency\": \"EUR\",\n    \"subtotal_amount\": 5000,\n    \"discount_amount\": 500,\n    \"shipping_amount\": 500,\n    \"tax_amount\": 950,\n    \"total_amount\": 5950,\n    \"billing_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"shipping_address_json\": {\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"address1\": \"123 Main St\",\n      \"city\": \"Berlin\",\n      \"country_code\": \"DE\",\n      \"postal_code\": \"10115\"\n    },\n    \"lines\": [\n      {\n        \"id\": 301,\n        \"product_id\": 10,\n        \"variant_id\": 101,\n        \"title_snapshot\": \"Classic T-Shirt\",\n        \"sku_snapshot\": \"TSH-BLU-M\",\n        \"quantity\": 2,\n        \"unit_price_amount\": 2500,\n        \"total_amount\": 5000,\n        \"tax_lines_json\": [\n          { \"rate\": 1900, \"amount\": 950, \"jurisdiction\": \"DE\" }\n        ],\n        \"discount_allocations_json\": [\n          { \"code\": \"WELCOME10\", \"amount\": 500 }\n        ]\n      }\n    ],\n    \"payments\": [\n      {\n        \"id\": 401,\n        \"provider\": \"mock\",\n        \"method\": \"credit_card\",\n        \"provider_payment_id\": \"mock_abc123\",\n        \"status\": \"captured\",\n        \"amount\": 5950,\n        \"currency\": \"EUR\",\n        \"created_at\": \"2026-02-11T10:10:00Z\"\n      }\n    ],\n    \"fulfillments\": [],\n    \"refunds\": [],\n    \"placed_at\": \"2026-02-11T10:10:00Z\",\n    \"created_at\": \"2026-02-11T10:10:00Z\",\n    \"updated_at\": \"2026-02-11T10:10:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments\n\nCreate a fulfillment (mark items as shipped).\n\n**Request body:**\n\n```json\n{\n  \"tracking_company\": \"DHL\",\n  \"tracking_number\": \"1234567890\",\n  \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 2\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `tracking_company` | string | No | Max: 255 |\n| `tracking_number` | string | No | Max: 255 |\n| `tracking_url` | string | No | Valid URL, max: 2048 |\n| `line_items` | array | Yes | At least one line item. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed unfulfilled quantity. |\n| `notify_customer` | boolean | No | Default: true. Sends shipping notification email. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 501,\n    \"order_id\": 50,\n    \"status\": \"shipped\",\n    \"tracking_company\": \"DHL\",\n    \"tracking_number\": \"1234567890\",\n    \"tracking_url\": \"https://www.dhl.com/track/1234567890\",\n    \"shipped_at\": \"2026-02-12T08:00:00Z\",\n    \"line_items\": [\n      {\n        \"order_line_id\": 301,\n        \"quantity\": 2\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order is not in a fulfillable state (cancelled, already fully fulfilled)\n- `422` - Validation errors (exceeds unfulfilled quantity)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds\n\nCreate a refund. Supports partial refunds by line or amount.\n\n**Request body:**\n\n```json\n{\n  \"amount\": 2500,\n  \"reason\": \"Customer requested return for 1 item\",\n  \"line_items\": [\n    {\n      \"order_line_id\": 301,\n      \"quantity\": 1\n    }\n  ],\n  \"notify_customer\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |\n| `reason` | string | No | Max: 1000 |\n| `line_items` | array | No | Specific lines being refunded. |\n| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |\n| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |\n| `notify_customer` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 601,\n    \"order_id\": 50,\n    \"payment_id\": 401,\n    \"provider_refund_id\": \"re_abc123\",\n    \"amount\": 2500,\n    \"reason\": \"Customer requested return for 1 item\",\n    \"status\": \"completed\",\n    \"created_at\": \"2026-02-13T09:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Order not found\n- `409` - Order cannot be refunded (not paid, fully refunded already)\n- `422` - Validation errors (amount exceeds refundable total)\n- `502` - Payment provider refund failed\n\n---\n\n### 3.5 Discounts\n\n#### Admin Discounts Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/discounts\n\nList discounts.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `type` | string | No | Filter: `code`, `automatic`. |\n| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 20,\n      \"store_id\": 1,\n      \"type\": \"code\",\n      \"code\": \"WELCOME10\",\n      \"value_type\": \"percent\",\n      \"value_amount\": 10,\n      \"starts_at\": \"2026-01-01T00:00:00Z\",\n      \"ends_at\": \"2026-12-31T23:59:59Z\",\n      \"usage_limit\": 1000,\n      \"usage_count\": 42,\n      \"rules_json\": {\n        \"minimum_purchase_amount\": 2000,\n        \"applicable_product_ids\": [],\n        \"applicable_collection_ids\": [],\n        \"customer_eligibility\": \"all\"\n      },\n      \"created_at\": \"2026-01-01T00:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 8,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/discounts\n\nCreate a discount.\n\n**Request body:**\n\n```json\n{\n  \"type\": \"code\",\n  \"code\": \"SUMMER20\",\n  \"value_type\": \"percent\",\n  \"value_amount\": 20,\n  \"starts_at\": \"2026-06-01T00:00:00Z\",\n  \"ends_at\": \"2026-08-31T23:59:59Z\",\n  \"usage_limit\": 500,\n  \"rules_json\": {\n    \"minimum_purchase_amount\": 5000,\n    \"applicable_product_ids\": [],\n    \"applicable_collection_ids\": [5],\n    \"customer_eligibility\": \"all\",\n    \"once_per_customer\": true\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `type` | string | Yes | One of: `code`, `automatic`. |\n| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |\n| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |\n| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |\n| `starts_at` | string (ISO 8601) | Yes | Must be a valid datetime. |\n| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |\n| `usage_limit` | integer | No | Min: 1. Null for unlimited. |\n| `rules_json` | object | No | See rules fields below. |\n| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |\n| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |\n| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |\n| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |\n| `rules_json.once_per_customer` | boolean | No | Default: false. |\n\n**Success response:** `201 Created`\n\nReturns the full discount resource.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (duplicate code, invalid dates, etc.)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nUpdate a discount. Supports partial updates.\n\nSame fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}\n\nDelete a discount.\n\n**Authentication:** Bearer token with `write-discounts` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Discount deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Discount not found\n\n---\n\n### 3.6 Shipping\n\n#### Admin Shipping Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/shipping/zones\n\nList shipping zones with their rates.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"name\": \"Germany\",\n      \"countries_json\": [\"DE\"],\n      \"regions_json\": [],\n      \"rates\": [\n        {\n          \"id\": 1,\n          \"name\": \"Standard Shipping\",\n          \"type\": \"flat\",\n          \"config_json\": {\n            \"price_amount\": 500,\n            \"currency\": \"EUR\"\n          },\n          \"is_active\": true\n        },\n        {\n          \"id\": 3,\n          \"name\": \"Weight-Based Shipping\",\n          \"type\": \"weight\",\n          \"config_json\": {\n            \"currency\": \"EUR\",\n            \"tiers\": [\n              { \"min_weight_g\": 0, \"max_weight_g\": 1000, \"price_amount\": 500 },\n              { \"min_weight_g\": 1001, \"max_weight_g\": 5000, \"price_amount\": 800 },\n              { \"min_weight_g\": 5001, \"max_weight_g\": null, \"price_amount\": 1200 }\n            ]\n          },\n          \"is_active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones\n\nCreate a shipping zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Europe (EU)\",\n  \"countries_json\": [\"DE\", \"FR\", \"NL\", \"BE\", \"AT\", \"IT\", \"ES\"],\n  \"regions_json\": []\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |\n| `regions_json` | array of strings | No | Region/province codes for more granular zones. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors (overlapping countries with existing zones)\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}\n\nUpdate a shipping zone.\n\nSame fields as POST.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates\n\nAdd a shipping rate to a zone.\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Express Shipping\",\n  \"type\": \"flat\",\n  \"config_json\": {\n    \"price_amount\": 1200,\n    \"currency\": \"EUR\"\n  },\n  \"is_active\": true\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `name` | string | Yes | Max: 255 |\n| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |\n| `config_json` | object | Yes | Schema depends on type. |\n| `is_active` | boolean | No | Default: true. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Zone not found\n- `422` - Validation errors\n\n---\n\n### 3.7 Tax Settings\n\n#### Admin Tax Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/tax/settings\n\nGet tax configuration for a store.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"mode\": \"manual\",\n    \"provider\": \"none\",\n    \"prices_include_tax\": true,\n    \"config_json\": {\n      \"default_tax_rate\": 1900,\n      \"tax_rates\": [\n        {\n          \"country_code\": \"DE\",\n          \"rate\": 1900,\n          \"name\": \"MwSt\",\n          \"shipping_taxed\": true\n        },\n        {\n          \"country_code\": \"AT\",\n          \"rate\": 2000,\n          \"name\": \"USt\",\n          \"shipping_taxed\": true\n        }\n      ]\n    },\n    \"updated_at\": \"2026-02-10T14:00:00Z\"\n  }\n}\n```\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/tax/settings\n\nUpdate tax configuration.\n\n**Request body:**\n\n```json\n{\n  \"mode\": \"provider\",\n  \"provider\": \"stripe_tax\",\n  \"prices_include_tax\": false,\n  \"config_json\": {\n    \"stripe_tax_settings_id\": \"txr_abc123\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `mode` | string | Yes | One of: `manual`, `provider`. |\n| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |\n| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |\n| `config_json` | object | Yes | Schema depends on mode/provider. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n### 3.8 Themes\n\n#### Admin Themes Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/themes\n\nUpload or install a new theme.\n\n**Request body (multipart/form-data):**\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |\n| `name` | string | No | Max: 255. Defaults to theme manifest name. |\n\n**Success response:** `201 Created`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"store_id\": 1,\n    \"name\": \"Dawn\",\n    \"version\": \"1.0.0\",\n    \"status\": \"draft\",\n    \"published_at\": null,\n    \"created_at\": \"2026-02-11T10:00:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid theme archive (missing required templates, invalid manifest)\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish\n\nPublish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"published\",\n    \"published_at\": \"2026-02-11T10:05:00Z\"\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Theme has validation errors preventing publish\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings\n\nUpdate theme settings (customization values).\n\n**Request body:**\n\n```json\n{\n  \"settings_json\": {\n    \"colors\": {\n      \"primary\": \"#1a73e8\",\n      \"secondary\": \"#fbbc04\",\n      \"background\": \"#ffffff\",\n      \"text\": \"#202124\"\n    },\n    \"typography\": {\n      \"heading_font\": \"Inter\",\n      \"body_font\": \"Inter\"\n    },\n    \"header\": {\n      \"show_announcement_bar\": true,\n      \"announcement_text\": \"Free shipping on orders over 50 EUR!\"\n    }\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `settings_json` | object | Yes | Must conform to the theme's settings schema. |\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Theme not found\n- `422` - Settings do not conform to theme schema\n\n---\n\n### 3.9 Pages\n\n#### Admin Pages Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |\n| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |\n| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/pages\n\nList content pages.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `status` | string | No | Filter: `draft`, `published`. |\n| `page` | integer | No | Default: 1. |\n| `per_page` | integer | No | Default: 25, max: 100. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"store_id\": 1,\n      \"title\": \"About Us\",\n      \"handle\": \"about-us\",\n      \"status\": \"published\",\n      \"published_at\": \"2026-02-01T10:00:00Z\",\n      \"created_at\": \"2026-01-15T10:00:00Z\",\n      \"updated_at\": \"2026-02-01T10:00:00Z\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 25,\n    \"total\": 3,\n    \"last_page\": 1\n  }\n}\n```\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/pages\n\nCreate a content page.\n\n**Request body:**\n\n```json\n{\n  \"title\": \"Shipping Policy\",\n  \"handle\": \"shipping-policy\",\n  \"body_html\": \"<h2>Shipping Policy</h2><p>We ship worldwide...</p>\",\n  \"status\": \"published\"\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `title` | string | Yes | Max: 255 |\n| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |\n| `body_html` | string | No | Max: 65535 |\n| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |\n\n**Success response:** `201 Created`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Validation errors\n\n---\n\n#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nUpdate a content page.\n\nSame fields as POST. Supports partial updates.\n\n**Success response:** `200 OK`\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n- `422` - Validation errors\n\n---\n\n#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}\n\nDelete a content page.\n\n**Authentication:** Bearer token with `write-content` ability.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"message\": \"Page deleted\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Page not found\n\n---\n\n### 3.10 Search Admin\n\n#### Admin Search Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |\n\n#### POST /api/admin/v1/stores/{storeId}/search/reindex\n\nTrigger a full search reindex for the store. Runs asynchronously via a queued job.\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"message\": \"Reindex job queued.\",\n  \"job_id\": \"job_reindex_abc123\",\n  \"status\": \"queued\"\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `409` - Reindex already in progress\n- `429` - Rate limited\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/search/status\n\nGet the current search index status.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"store_id\": 1,\n    \"index_status\": \"ready\",\n    \"last_reindex_at\": \"2026-02-11T08:00:00Z\",\n    \"last_reindex_duration_seconds\": 45,\n    \"documents_count\": 342,\n    \"pending_updates\": 0\n  }\n}\n```\n\n---\n\n### 3.11 Analytics and Exports\n\n#### Admin Analytics Endpoint Summary\n\n| Method | Path | Auth Scope | Description | Rate Limit |\n|--------|------|------------|-------------|------------|\n| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |\n| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |\n| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |\n\n#### GET /api/admin/v1/stores/{storeId}/analytics/summary\n\nGet analytics summary for a date range.\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `from` | string (YYYY-MM-DD) | Yes | Start date. |\n| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |\n| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"period\": {\n      \"from\": \"2026-02-01\",\n      \"to\": \"2026-02-11\"\n    },\n    \"summary\": {\n      \"orders_count\": 156,\n      \"revenue_amount\": 1250000,\n      \"aov_amount\": 8012,\n      \"visits_count\": 4520,\n      \"add_to_cart_count\": 890,\n      \"checkout_started_count\": 420,\n      \"conversion_rate\": 0.0345,\n      \"currency\": \"EUR\"\n    },\n    \"daily\": [\n      {\n        \"date\": \"2026-02-01\",\n        \"orders_count\": 12,\n        \"revenue_amount\": 95000,\n        \"aov_amount\": 7917,\n        \"visits_count\": 380,\n        \"add_to_cart_count\": 72,\n        \"checkout_started_count\": 35\n      }\n    ],\n    \"top_products\": [\n      {\n        \"product_id\": 10,\n        \"title\": \"Classic T-Shirt\",\n        \"units_sold\": 45,\n        \"revenue_amount\": 112500\n      }\n    ]\n  }\n}\n```\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `422` - Invalid date range\n\n---\n\n#### POST /api/admin/v1/stores/{storeId}/exports/orders\n\nQueue an order export job. The export runs asynchronously and produces a downloadable CSV.\n\n**Request body:**\n\n```json\n{\n  \"format\": \"csv\",\n  \"filters\": {\n    \"status\": \"paid\",\n    \"created_after\": \"2026-01-01T00:00:00Z\",\n    \"created_before\": \"2026-02-11T23:59:59Z\"\n  }\n}\n```\n\n| Field | Type | Required | Validation |\n|-------|------|----------|------------|\n| `format` | string | No | One of: `csv`. Default: `csv`. |\n| `filters` | object | No | Same filter options as GET /orders. |\n\n**Success response:** `202 Accepted`\n\n```json\n{\n  \"export_id\": 1,\n  \"status\": \"queued\",\n  \"created_at\": \"2026-02-11T10:00:00Z\"\n}\n```\n\n---\n\n#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}\n\nCheck export status and get download URL.\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"data\": {\n    \"id\": 1,\n    \"status\": \"completed\",\n    \"format\": \"csv\",\n    \"row_count\": 156,\n    \"download_url\": \"https://s3.example.com/...presigned...\",\n    \"download_expires_at\": \"2026-02-11T11:00:00Z\",\n    \"created_at\": \"2026-02-11T10:00:00Z\",\n    \"completed_at\": \"2026-02-11T10:01:30Z\"\n  }\n}\n```\n\n**Export status values:** `queued`, `processing`, `completed`, `failed`.\n\n**Error responses:**\n- `403` - Insufficient permissions\n- `404` - Export not found\n\n---\n\n## 4. Webhook Receiver Endpoints\n\n> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).\n\n---\n\n## 5. OAuth Endpoints (App Ecosystem)\n\n> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.\n\nThese endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.\n\n---\n\n### GET /oauth/authorize\n\nDisplay the authorization prompt to the merchant.\n\n- **Middleware:** `web`, `auth`\n\n**Query parameters:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `client_id` | string | Yes | The app's OAuth client ID |\n| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |\n| `response_type` | string | Yes | Must be `code` |\n| `scope` | string | Yes | Space-separated list of requested scopes |\n| `state` | string | Yes | CSRF token for the client |\n\n**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.\n\n---\n\n### POST /oauth/token\n\nExchange an authorization code for access and refresh tokens.\n\n- **Middleware:** None (public endpoint)\n\n**Authorization code exchange request:**\n\n```json\n{\n  \"grant_type\": \"authorization_code\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"redirect_uri\": \"https://myapp.example.com/callback\",\n  \"code\": \"auth_code_abc123\"\n}\n```\n\n**Success response:** `200 OK`\n\n```json\n{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"access_token\": \"eyJ...\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n**Error responses:**\n- `400` - Invalid grant (code already used, expired, etc.)\n- `401` - Invalid client credentials\n\n**Refresh token request:**\n\n```json\n{\n  \"grant_type\": \"refresh_token\",\n  \"client_id\": \"app_client_abc123\",\n  \"client_secret\": \"secret_xyz\",\n  \"refresh_token\": \"def...\"\n}\n```\n\n---\n\n### App API Endpoints\n\nThird-party apps use OAuth tokens to access store data via scoped endpoints.\n\n| Method | Path | Scope Required | Description |\n|--------|------|----------------|-------------|\n| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |\n| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |\n| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |\n\nToken scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.\n\n---\n\n## 6. Middleware Stack\n\n### 6.1 Route Group Middleware\n\n#### Web (default)\n\nApplied to all web routes via the `web` middleware group:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `EncryptCookies` | Encrypt/decrypt cookies |\n| `AddQueuedCookiesToResponse` | Attach queued cookies |\n| `StartSession` | Session handling |\n| `ShareErrorsFromSession` | Flash validation errors |\n| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |\n| `SubstituteBindings` | Route model binding |\n\n#### Admin\n\nApplied to `/admin/*` routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth` | Require authenticated user (Laravel's built-in guard) |\n| `verified` | Require verified email address |\n| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |\n| `role.check` | Verify user has required role for the current store |\n\n#### Storefront\n\nApplied to storefront routes in addition to `web`:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `store.resolve` | Resolve store from request hostname via `store_domains` table |\n\n#### API Storefront\n\nApplied to `/api/storefront/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `throttle:api.storefront` | Rate limiting (120/min per IP) |\n| `store.resolve` | Resolve store from request hostname |\n\n#### API Admin\n\nApplied to `/api/admin/v1/*` routes:\n\n| Middleware | Purpose |\n|-----------|---------|\n| `auth:sanctum` | Sanctum token authentication |\n| `store.resolve` | Resolve store from route parameter or token context |\n| `throttle:api.admin` | Rate limiting (60/min per token) |\n\n---\n\n### 6.2 Custom Middleware Definitions\n\n#### `store.resolve`\n\nResolves the current store context. The resolution strategy depends on the route group:\n\n- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).\n- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.\n- **API (storefront):** Same as storefront web - hostname-based resolution.\n- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.\n\nSets `app.store_id` in the service container and binds the `Store` model to the request.\n\n**Failure responses:**\n- `404` if store cannot be resolved from hostname.\n- `403` if user does not have access to the requested store.\n- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.\n\n#### `role.check`\n\nChecks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.\n\n**Failure response:** `403 Forbidden`\n\n---\n\n## 7. Rate Limiting Configuration\n\n### Rate Limit Tiers\n\n| Tier | Limit | Window | Key | Description |\n|------|-------|--------|-----|-------------|\n| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |\n| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |\n| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |\n| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |\n| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |\n| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |\n\n### Rate Limit Response\n\nWhen a rate limit is exceeded, the server returns `429 Too Many Requests`:\n\n```json\n{\n  \"message\": \"Too many requests. Please try again later.\",\n  \"retry_after\": 42\n}\n```\n\n**Headers included on all rate-limited responses:**\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429) |\n\n### Per-Endpoint Rate Limit Mapping\n\n| Endpoint Group | Tier |\n|---------------|------|\n| `POST /admin/login` | `login` |\n| `POST /account/login` | `login` |\n| `POST /api/storefront/v1/carts*` | `api.storefront` |\n| `GET /api/storefront/v1/carts*` | `api.storefront` |\n| `PUT /api/storefront/v1/carts*` | `api.storefront` |\n| `DELETE /api/storefront/v1/carts*` | `api.storefront` |\n| `POST /api/storefront/v1/checkouts*` | `checkout` |\n| `GET /api/storefront/v1/checkouts*` | `checkout` |\n| `PUT /api/storefront/v1/checkouts*` | `checkout` |\n| `DELETE /api/storefront/v1/checkouts*` | `checkout` |\n| `POST /api/storefront/v1/checkouts/*/pay` | `checkout` |\n| `GET /api/storefront/v1/orders*` | `api.storefront` |\n| `GET /api/storefront/v1/search` | `search` |\n| `GET /api/storefront/v1/search/suggest` | `search` |\n| `POST /api/storefront/v1/analytics/events` | `analytics` |\n| `GET /api/admin/v1/*` | `api.admin` |\n| `POST /api/admin/v1/*` | `api.admin` |\n| `PUT /api/admin/v1/*` | `api.admin` |\n| `DELETE /api/admin/v1/*` | `api.admin` |\n| *(no inbound webhook endpoints - mock PSP)* | - |\n\n---\n\n## 8. Checkout State Machine\n\nThe checkout follows a strict state machine with explicit transitions:\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : POST /checkouts (create from cart)\n    started --> addressed : PUT /checkouts/{id}/address\n    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method\n    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method\n    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)\n    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)\n    payment_selected --> shipping_selected : Payment declined, customer retries\n    started --> expired : 24-hour TTL reached\n    addressed --> expired : 24-hour TTL reached\n    shipping_selected --> expired : 24-hour TTL reached\n    payment_selected --> expired : 24-hour TTL reached\n    completed --> [*]\n```\n\n### Checkout Status Transition Table\n\n| From | To | Trigger |\n|------|----|---------|\n| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |\n| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |\n| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |\n| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |\n| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |\n| `*` (any active) | `expired` | Checkout expires_at reached (24-hour TTL, checked via scheduled job) |\n\nApplying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.\n\n---\n\n## 9. API Versioning Strategy\n\n- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.\n- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.\n- **App API:** Versioned via URL path (`/api/apps/v1/`).\n- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version.\n\nNon-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.\n\n---\n\n## 10. Error Response Format\n\nAll API error responses follow a consistent JSON structure:\n\n### Validation Errors (422)\n\n```json\n{\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"title\": [\"The title field is required.\"],\n    \"variants.0.sku\": [\"The sku has already been taken.\"]\n  }\n}\n```\n\n### Authorization Errors (403)\n\n```json\n{\n  \"message\": \"You do not have permission to perform this action.\"\n}\n```\n\n### Not Found Errors (404)\n\n```json\n{\n  \"message\": \"The requested resource was not found.\"\n}\n```\n\n### Conflict Errors (409)\n\n```json\n{\n  \"message\": \"The cart has been modified. Please refresh and try again.\",\n  \"error_code\": \"version_conflict\",\n  \"current_version\": 4\n}\n```\n\n### Business Logic Errors (400)\n\n```json\n{\n  \"message\": \"This discount code has expired.\",\n  \"error_code\": \"discount_expired\"\n}\n```\n\n### Server Errors (500, 502)\n\n```json\n{\n  \"message\": \"An internal server error occurred. Please try again later.\",\n  \"reference_id\": \"err_abc123\"\n}\n```\n\nThe `reference_id` correlates with structured log entries for debugging.\n","structuredPatch":[{"oldStart":89,"oldLines":7,"newStart":89,"newLines":7,"lines":[" "," | Method | Path | Description | Component |"," |--------|------|-------------|-----------|","-| GET | `/admin/settings` | Store settings (tabs: General, Domains, Localization, Checkout, Notifications, API tokens) | `Admin\\Settings\\Index` |","+| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\\Settings\\Index` |"," | GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\\Settings\\Shipping` |"," | GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\\Settings\\Taxes` |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"51b00178-cc98-47f6-bd1c-468bffbdb227"}
{"parentUuid":"3e088ee3-b8d7-4891-822f-804d06aa4f90","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016H1j4NXWu84xvW6yPAV9tx","type":"message","role":"assistant","content":[{"type":"text","text":"Finding #7 fixed. Now **Finding #8 (Medium):** Fix browser test totals. The top summary in spec 08 says 143, but the bottom summary still says 134. Also fix in spec 09."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1298,"cache_read_input_tokens":64932,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1298},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YPoEQVyjctUGXDLzRp","type":"assistant","uuid":"41f135f1-b658-4680-bec7-a327b44c18ae","timestamp":"2026-02-11T15:46:31.698Z"}
{"parentUuid":"41f135f1-b658-4680-bec7-a327b44c18ae","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_016H1j4NXWu84xvW6yPAV9tx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011Uq19gASvy1gkgD5NWHN6h","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","offset":22,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1298,"cache_read_input_tokens":64932,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1298},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YPoEQVyjctUGXDLzRp","type":"assistant","uuid":"36e527b2-be9a-4f97-bf66-8aab48d86c22","timestamp":"2026-02-11T15:46:32.421Z"}
{"parentUuid":"36e527b2-be9a-4f97-bf66-8aab48d86c22","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_011Uq19gASvy1gkgD5NWHN6h","toolUseID":"toolu_011Uq19gASvy1gkgD5NWHN6h","timestamp":"2026-02-11T15:46:32.424Z","uuid":"d2951457-998a-45ea-a53a-6f98cd745a16"}
{"parentUuid":"36e527b2-be9a-4f97-bf66-8aab48d86c22","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011Uq19gASvy1gkgD5NWHN6h","type":"tool_result","content":"    22→| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n    23→| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n    24→| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n    25→| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n    26→| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n    27→| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n    28→| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n    29→| **Total** | **18 files** | **143** | | |\n    30→\n    31→---\n    32→\n    33→## Test Infrastructure Requirements\n    34→\n    35→### Framework\n    36→"}]},"uuid":"0e98bbe2-9b9a-4304-80e8-bc1f428a12a3","timestamp":"2026-02-11T15:46:32.423Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","content":"| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n","numLines":15,"startLine":22,"totalLines":3174}},"sourceToolAssistantUUID":"36e527b2-be9a-4f97-bf66-8aab48d86c22"}
{"parentUuid":"0e98bbe2-9b9a-4304-80e8-bc1f428a12a3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01XhvmUAJGr58QjPdyPQjtFQ","type":"message","role":"assistant","content":[{"type":"text","text":"Good, the top summary is already 143. Now let me check the suite-level counts to verify the correct total, then fix the bottom summary."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":565,"cache_read_input_tokens":66230,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":565},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YQ7vogY4LxC9FfsiVX","type":"assistant","uuid":"6d50beca-feb7-46e8-8e58-09e3def15fa4","timestamp":"2026-02-11T15:46:35.711Z"}
{"parentUuid":"6d50beca-feb7-46e8-8e58-09e3def15fa4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01XhvmUAJGr58QjPdyPQjtFQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012AnCJCpNCcbW7WNcHywGjB","name":"Grep","input":{"pattern":"^\\| \\*\\*Total\\*\\*","path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","output_mode":"content"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":565,"cache_read_input_tokens":66230,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":565},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YQ7vogY4LxC9FfsiVX","type":"assistant","uuid":"50eaf9f1-22cd-48ad-852b-be4257c6b040","timestamp":"2026-02-11T15:46:36.778Z"}
{"parentUuid":"50eaf9f1-22cd-48ad-852b-be4257c6b040","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_012AnCJCpNCcbW7WNcHywGjB","toolUseID":"toolu_012AnCJCpNCcbW7WNcHywGjB","timestamp":"2026-02-11T15:46:37.063Z","uuid":"8c899a8c-6871-4c7a-973f-19b853634029"}
{"parentUuid":"50eaf9f1-22cd-48ad-852b-be4257c6b040","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012AnCJCpNCcbW7WNcHywGjB","type":"tool_result","content":"29:| **Total** | **18 files** | **143** | | |\n1739:| **Total** | **29.98 EUR** |\n1748:| **Total** | **24.98 EUR** |\n3140:| **Total** | **18 files** | **134** |"}]},"uuid":"a69791ef-52b0-471f-8911-bf2e8b01f07b","timestamp":"2026-02-11T15:46:37.062Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"29:| **Total** | **18 files** | **143** | | |\n1739:| **Total** | **29.98 EUR** |\n1748:| **Total** | **24.98 EUR** |\n3140:| **Total** | **18 files** | **134** |","numLines":4},"sourceToolAssistantUUID":"50eaf9f1-22cd-48ad-852b-be4257c6b040"}
{"parentUuid":"a69791ef-52b0-471f-8911-bf2e8b01f07b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01LmiQQZUPuS4eaFwpFBiGeN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FqyLb2uHexHAZvmXpQcXV6","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","old_string":"| **Total** | **18 files** | **134** |","new_string":"| **Total** | **18 files** | **143** |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":243,"cache_read_input_tokens":66795,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":243},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YQTRKNq2MWfxb8Xpq1","type":"assistant","uuid":"c3f436cf-df05-4e37-a0a9-a0925ce38eac","timestamp":"2026-02-11T15:46:40.868Z"}
{"parentUuid":"c3f436cf-df05-4e37-a0a9-a0925ce38eac","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01FqyLb2uHexHAZvmXpQcXV6","toolUseID":"toolu_01FqyLb2uHexHAZvmXpQcXV6","timestamp":"2026-02-11T15:46:40.877Z","uuid":"37a3976c-9e08-4ba9-a7a9-edb45d025f01"}
{"parentUuid":"c3f436cf-df05-4e37-a0a9-a0925ce38eac","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FqyLb2uHexHAZvmXpQcXV6","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md has been updated successfully."}]},"uuid":"fb87d6d5-b52c-4f53-8336-aeb5e9c8563a","timestamp":"2026-02-11T15:46:40.877Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/08-PLAYWRIGHT-E2E-PLAN.md","oldString":"| **Total** | **18 files** | **134** |","newString":"| **Total** | **18 files** | **143** |","originalFile":"# 08 - Playwright E2E Test Plan\n\n> Complete end-to-end browser test specification for the Shopify-clone platform using Pest v4 browser testing (Playwright-backed). All tests verify the entire application works through a real browser. This document defines requirements and specifications for the coding agent to implement.\n\n---\n\n## Test Suite Summary\n\n| Suite | File | Tests | Purpose | Dependencies |\n|-------|------|:-----:|---------|--------------|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 | Hit every major page, assert HTTP 200 and no JS errors | Store 1, Products, Collections, Pages |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 | Admin login, logout, invalid credentials, session access control | Admin user |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 | Product CRUD: listing, creating, editing, archiving, filtering | Admin user, Products (incl. #15 draft) |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 11 | Order listing, filtering, detail view, fulfillment, refund, confirm payment, fulfillment guard, mark shipped/delivered | Admin user, Orders (#1001, #1005), Customer |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 | Discount code listing, creation, editing | Admin user, Discounts |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 | Store settings, shipping zones, tax config, domains | Admin user, Shipping zones, Store 1 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 | Home page, collections, product detail, variants, search, pages | Products, Collections, Pages, Products #15/#17/#18 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 | Cart lifecycle: add, update qty, remove, discounts, totals | Products, Discounts (all codes) |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 13 | Full checkout: contact, address, shipping, payment method selection, payment, confirmation, magic card decline, bank transfer flow | Products, Shipping zones, Discounts |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 | Registration, login, order history, addresses, logout | Customer user, Orders (#1001/#1002/#1004) |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 | Inventory policy enforcement: deny vs. continue | Products #17 (deny), #18 (continue), #1 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 | Multi-store data isolation verification | Store 1, Admin user, Customer user |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 | Mobile and tablet viewport rendering and interaction | Products, Admin user |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 | Heading hierarchy, ARIA labels, form labels, keyboard nav | Products, Store 1 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 | Collection CRUD in admin | Admin user, Collections |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 | Customer listing and detail views | Admin user, Customer user, Orders |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 | CMS page management | Admin user, Pages |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 | Analytics dashboard rendering | Admin user, Orders |\n| **Total** | **18 files** | **143** | | |\n\n---\n\n## Test Infrastructure Requirements\n\n### Framework\n\n- **Test runner:** Pest v4 with browser testing capabilities (Playwright under the hood)\n- **Test traits:** Each browser test file must use `TestCase` and `RefreshDatabase`\n- **Configuration:** Register the `Browser` test directory in `tests/Pest.php` with appropriate base classes\n- **Base URL:** Configured via `APP_URL` in `.env.testing`, set to `http://acme-fashion.test`\n\n### Pest v4 Browser Testing API Reference\n\nThe coding agent should use these Pest v4 browser methods:\n\n| Method | Purpose |\n|--------|---------|\n| `visit(url)` | Open a real browser and navigate to the given URL |\n| `visit([url1, url2, ...])` | Visit multiple pages (for batch smoke testing) |\n| `fill(field, value)` | Type a value into a named form field |\n| `click(text)` | Click a visible element matching the given text |\n| `assertSee(text)` | Assert the page contains the given visible text |\n| `assertNoJavascriptErrors()` | Assert no uncaught JS exceptions occurred |\n| `assertNoConsoleLogs()` | Assert no console warnings or errors were logged |\n\nAdditionally, standard Laravel test features are available within browser tests: factories, `Event::fake()`, `Notification::fake()`, `$this->actingAs()`, and model queries.\n\n### Environment Configuration\n\nThe `.env.testing` file must contain the following keys:\n\n| Key | Value | Purpose |\n|-----|-------|---------|\n| `APP_ENV` | `testing` | Laravel environment |\n| `APP_URL` | `http://acme-fashion.test` | Browser base URL (served by Herd) |\n| `DB_CONNECTION` | `sqlite` | Database driver |\n| `DB_DATABASE` | Absolute path to test SQLite file | Isolated test database |\n| `PAYMENT_PROVIDER` | `mock` | Mock PSP - no external API keys needed |\n| `MAIL_MAILER` | `array` | Trap emails in memory |\n| `QUEUE_CONNECTION` | `sync` | Process jobs synchronously |\n\n### Prerequisites Before Running\n\n1. Database seeded with `php artisan migrate:fresh --seed`\n2. Laravel Herd serving `acme-fashion.test`\n3. Playwright browsers installed via `npx playwright install`\n4. Frontend assets built via `npm run build`\n\n---\n\n## Seeded Data Dependency Table\n\nAll tests depend on the database seeder providing this baseline data. Each test gets a fresh database via `RefreshDatabase`, then the seeder repopulates it.\n\nEach browser test file must set `protected bool $seed = true;` on the test class (or call `$this->seed(DatabaseSeeder::class)` in `beforeEach`) to ensure the database is populated after RefreshDatabase clears it. Laravel's `RefreshDatabase` trait does NOT automatically run seeders unless explicitly configured.\n\n| Entity | Key Details | Used By Suites |\n|--------|-------------|----------------|\n| **Admin user** | Email: `admin@acme.test`, password: `password`, role: `owner` on Store 1 | 2, 3, 4, 5, 6, 12, 13, 15, 16, 17, 18 |\n| **Customer user** | Email: `customer@acme.test`, password: `password`, name: \"John Doe\", belongs to Store 1 | 10, 12 |\n| **Store 1** | Name: \"Acme Fashion\", handle: `acme-fashion`, domain: `acme-fashion.test`, currency: EUR | 1, 6, 7, 12, 14 |\n| **Product #1** | Title: \"Classic Cotton T-Shirt\", handle: `classic-cotton-t-shirt`, price: 24.99 EUR, status: active, options: Size (S/M/L/XL), Color (Black/White/Navy), inventory: in stock | 1, 3, 7, 8, 9, 11, 13, 14 |\n| **Product #2** | Title: \"Premium Slim Fit Jeans\", handle: `premium-slim-fit-jeans`, status: active, has compare-at pricing (sale), options: Size, Color | 7, 8 |\n| **Product #15** | Status: `draft` - must NOT appear on storefront | 3, 7 |\n| **Product #17** | Status: active, inventory: 0 on hand, policy: `deny` - displays \"Sold out\", add-to-cart disabled | 7, 11 |\n| **Product #18** | Status: active, inventory: 0 on hand, policy: `continue` - displays \"Available on backorder\", add-to-cart enabled | 7, 11 |\n| **Products (20 total)** | Seeded across collections, with variants, options, images, inventory | 1, 3, 7, 8, 9, 11, 13 |\n| **Collections** | \"T-Shirts\" (handle: `t-shirts`), \"New Arrivals\" (handle: `new-arrivals`), plus others | 1, 7, 13, 15 |\n| **Orders** | #1001 (status: paid, fulfillment: unfulfilled, customer: customer@acme.test), #1002, #1004 also belonging to the same customer | 4, 10, 16, 18 |\n| **Discounts** | WELCOME10 (10% off, min 20 EUR purchase, active), FLAT5 (5 EUR fixed, active), FREESHIP (free shipping, active), EXPIRED20 (expired end date), MAXED (usage_count >= usage_limit) | 5, 8, 9 |\n| **Shipping zones** | \"Domestic\" zone (countries: DE) with rate \"Standard Shipping\" at 4.99 EUR flat; \"International\" zone with its own rates | 6, 9 |\n| **Tax settings** | Store 1: manual mode, prices_include_tax varies by test | 6 |\n| **Pages** | \"About\" (handle: `about`, status: published) | 1, 7, 17 |\n| **Navigation** | Main menu with links to collections, about page | 7 |\n\n---\n\n## Test Execution Matrix\n\nTests should be executed in the order shown below. Later suites depend on the infrastructure and pages verified by earlier suites.\n\n```mermaid\ngraph TD\n    S1[Suite 1: Smoke Tests] --> S2[Suite 2: Admin Auth]\n    S1 --> S7[Suite 7: Storefront Browsing]\n    S2 --> S3[Suite 3: Admin Products]\n    S2 --> S4[Suite 4: Admin Orders]\n    S2 --> S5[Suite 5: Admin Discounts]\n    S2 --> S6[Suite 6: Admin Settings]\n    S2 --> S15[Suite 15: Admin Collections]\n    S2 --> S16[Suite 16: Admin Customers]\n    S2 --> S17[Suite 17: Admin Pages]\n    S2 --> S18[Suite 18: Admin Analytics]\n    S7 --> S8[Suite 8: Cart Flow]\n    S8 --> S9[Suite 9: Checkout Flow]\n    S7 --> S10[Suite 10: Customer Account]\n    S7 --> S11[Suite 11: Inventory Enforcement]\n    S7 --> S12[Suite 12: Tenant Isolation]\n    S7 --> S13[Suite 13: Responsive / Mobile]\n    S7 --> S14[Suite 14: Accessibility]\n```\n\n| Execution Phase | Suites | Rationale |\n|-----------------|--------|-----------|\n| Phase 1 | Suite 1 (Smoke) | Verifies all routes load before deeper testing |\n| Phase 2 | Suites 2, 7 | Core auth and storefront browsing - gate to all further tests |\n| Phase 3 | Suites 3, 4, 5, 6, 10, 11, 15, 16, 17, 18 | Admin CRUD and storefront feature suites (can run in parallel) |\n| Phase 4 | Suite 8 (Cart) | Depends on storefront browsing working |\n| Phase 5 | Suite 9 (Checkout) | Depends on cart flow working. Tests payment method selection (credit card, PayPal, bank transfer), magic card declines, and bank transfer instructions. |\n| Phase 6 | Suites 12, 13, 14 | Cross-cutting concerns: isolation, responsive, accessibility |\n\n---\n\n## Test Directory Structure\n\n```\ntests/Browser/\n    SmokeTest.php\n    Admin/\n        AuthenticationTest.php\n        ProductManagementTest.php\n        OrderManagementTest.php\n        DiscountManagementTest.php\n        SettingsTest.php\n        CollectionManagementTest.php\n        CustomerManagementTest.php\n        PageManagementTest.php\n        AnalyticsTest.php\n    Storefront/\n        BrowsingTest.php\n        CartTest.php\n        CheckoutTest.php\n        CustomerAccountTest.php\n        InventoryTest.php\n        TenantIsolationTest.php\n        ResponsiveTest.php\n        AccessibilityTest.php\n```\n\n---\n\n## Suite 1: Smoke Tests\n\n**File:** `tests/Browser/SmokeTest.php`\n\n**Purpose:** Hit every major public and admin page, assert they load without JavaScript errors. This is the fastest way to catch broken routes, missing views, or uncaught JS exceptions.\n\n---\n\n### Test 1.1: Loads the storefront home page\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 1.2: Loads a collection page\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n---\n\n### Test 1.3: Loads a product page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 1.4: Loads the cart page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- No JavaScript errors\n\n---\n\n### Test 1.5: Loads the customer login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- Page displays \"Log in\"\n- No JavaScript errors\n\n---\n\n### Test 1.6: Loads the admin login page\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- Page displays \"Sign in\"\n- No JavaScript errors\n\n---\n\n### Test 1.7: Loads the about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 1.8: Loads the search page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Page displays \"shirt\"\n- No JavaScript errors\n\n---\n\n### Test 1.9: Loads all collections listing\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Page displays \"Collections\"\n- No JavaScript errors\n\n---\n\n### Test 1.10: Has no errors on critical pages (batch)\n\n**Preconditions:** All seed data\n\n**Steps:**\n1. Visit all critical pages in batch: `/`, `/collections/new-arrivals`, `/products/classic-cotton-t-shirt`, `/cart`, `/account/login`, `/admin/login`, `/pages/about`, `/search?q=shirt`\n\n**Expected Results:**\n- No JavaScript errors on any of the visited pages\n\n**Total: 10 tests**\n\n---\n\n## Suite 2: Admin Authentication\n\n**File:** `tests/Browser/Admin/AuthenticationTest.php`\n\n**Purpose:** Verify the admin login flow, credential validation, session management, logout, and sidebar navigation after successful login.\n\n### Authentication Flow\n\n```mermaid\nstateDiagram-v2\n    [*] --> LoginPage: visit /admin/login\n    LoginPage --> Dashboard: valid credentials + click \"Sign in\"\n    LoginPage --> LoginPage: invalid credentials (show error)\n    LoginPage --> LoginPage: empty fields (show validation)\n    Dashboard --> Products: click \"Products\"\n    Dashboard --> Orders: click \"Orders\"\n    Dashboard --> Customers: click \"Customers\"\n    Dashboard --> Discounts: click \"Discounts\"\n    Dashboard --> Settings: click \"Settings\"\n    Dashboard --> Analytics: click \"Analytics\"\n    Dashboard --> Themes: click \"Themes\"\n    Dashboard --> LoginPage: click profile dropdown then \"Logout\"\n```\n\n---\n\n### Test 2.1: Can log in as admin\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 2.2: Shows error for invalid credentials\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `wrongpassword`\n4. Click the \"Sign in\" button\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 2.3: Shows error for empty email\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter password: `password` (leave email empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.4: Shows error for empty password\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test` (leave password empty)\n3. Click the \"Sign in\" button\n\n**Expected Results:**\n- Validation error referencing \"password\" is visible\n- No JavaScript errors\n\n---\n\n### Test 2.5: Redirects unauthenticated users to login from dashboard\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.6: Redirects unauthenticated users to login from products\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/admin/products` (without logging in)\n\n**Expected Results:**\n- Page displays \"Sign in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 2.7: Can log out\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Verify \"Dashboard\" is visible\n6. Click on \"Admin User\" (profile/user menu)\n7. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Sign in\" (returned to login form)\n\n---\n\n### Test 2.8: Can navigate through admin sidebar sections\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n6. Verify \"Products\" heading is visible\n7. Click \"Orders\" in the sidebar\n8. Verify \"Orders\" heading is visible\n9. Click \"Customers\" in the sidebar\n10. Verify \"Customers\" heading is visible\n11. Click \"Discounts\" in the sidebar\n12. Verify \"Discounts\" heading is visible\n13. Click \"Settings\" in the sidebar\n14. Verify \"Store Settings\" heading is visible\n\n**Expected Results:**\n- Each section loads without JavaScript errors\n- Each section heading is visible after clicking its sidebar link\n\n---\n\n### Test 2.9: Can navigate to analytics from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 2.10: Can navigate to themes from sidebar\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Themes\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Themes\"\n- No JavaScript errors\n\n**Login Helper Pattern:** Tests 2.7-2.10 require the admin to be logged in first. Each test should perform the full login sequence (visit login page, fill credentials, click sign in, assert dashboard) before proceeding. This ensures test independence.\n\n**Total: 10 tests**\n\n---\n\n## Suite 3: Admin Product Management\n\n**File:** `tests/Browser/Admin/ProductManagementTest.php`\n\n**Purpose:** Verify CRUD operations for products through the admin interface - listing, creating, editing, archiving, searching, and filtering by status.\n\n### Product Lifecycle States\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft: create product (default)\n    Draft --> Active: set status to Active + Save\n    Active --> Archived: set status to Archived + Save\n    Draft --> Archived: set status to Archived + Save\n    Archived --> Active: set status to Active + Save\n    Active --> Draft: set status to Draft + Save\n```\n\n---\n\n### Test 3.1: Shows the product list with seeded products\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Enter email: `admin@acme.test`\n3. Enter password: `password`\n4. Click the \"Sign in\" button\n5. Click \"Products\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 3.2: Can create a new product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Test Product Created by E2E`\n6. Enter description: `This product was created by the E2E test suite.`\n7. Enter vendor: `Test Vendor`\n8. Enter product type: `T-Shirts`\n9. Enter variant price (variants.0.price): `29.99`\n10. Enter variant SKU (variants.0.sku): `E2E-TEST-001`\n11. Enter variant quantity (variants.0.quantity): `50`\n12. Click \"Save\"\n13. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 12: page displays \"Product saved\", no JavaScript errors\n- After step 13: page displays \"Test Product Created by E2E\" in the list\n\n---\n\n### Test 3.3: Can edit an existing product title\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify \"Classic Cotton T-Shirt\" is visible in the list\n5. Click \"Classic Cotton T-Shirt\" to open the edit form\n6. Clear the title field and enter: `Classic Cotton T-Shirt Updated`\n7. Click \"Save\"\n8. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 7: page displays \"Product saved\", no JavaScript errors\n- After step 8: page displays \"Classic Cotton T-Shirt Updated\" in the list\n\n---\n\n### Test 3.4: Can archive a product\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click \"Add product\"\n5. Enter title: `Product To Archive`\n6. Enter variant price: `19.99`\n7. Enter variant SKU: `E2E-ARCHIVE-001`\n8. Enter variant quantity: `10`\n9. Click \"Save\"\n10. Verify \"Product saved\" is displayed\n11. Click \"Products\" to return to the product list\n12. Click \"Product To Archive\" to open the edit form\n13. Change status to \"Archived\" (click the Archived option)\n14. Click \"Save\"\n15. Click \"Products\" to return to the product list\n\n**Expected Results:**\n- After step 9: page displays \"Product saved\"\n- After step 14: page displays \"Product saved\"\n- After step 15: with default \"Active\" filter, the archived product should not appear in the list\n\n---\n\n### Test 3.5: Shows draft products only in admin, not storefront\n\n**Preconditions:** Logged in as admin, Product #15 exists with draft status\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Verify Product #15 is visible in the admin list with a \"Draft\" badge\n5. Open a new browser context / navigate to `/collections/t-shirts` (as guest)\n6. Verify the draft product does NOT appear in the collection listing\n7. Navigate to `/search?q=draft`\n8. Verify the draft product does NOT appear in search results\n\n**Expected Results:**\n- Step 4: draft product visible in admin, no JavaScript errors\n- Step 6: draft product absent from storefront collection, no JavaScript errors\n- Step 8: draft product absent from search results, no JavaScript errors\n\n---\n\n### Test 3.6: Can search products in admin\n\n**Preconditions:** Logged in as admin, seeded products\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Enter \"Cotton\" in the search input field\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in filtered results\n- No JavaScript errors\n\n---\n\n### Test 3.7: Can filter products by status in admin\n\n**Preconditions:** Logged in as admin, seeded products including drafts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Products\" in the sidebar\n4. Click the \"Draft\" status filter tab\n5. Verify only draft products are visible\n6. Click the \"Active\" status filter tab\n\n**Expected Results:**\n- After step 4: only draft products are visible, no JavaScript errors\n- After step 6: \"Classic Cotton T-Shirt\" is visible, no JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 4: Admin Order Management\n\n**File:** `tests/Browser/Admin/OrderManagementTest.php`\n\n**Purpose:** Verify order listing, status filtering, order detail with timeline, fulfillment creation, and refund processing.\n\n### Order State Transitions (via admin actions)\n\n```mermaid\nstateDiagram-v2\n    state financial {\n        pending_f --> paid: payment captured\n        paid --> partially_refunded: partial refund\n        paid --> refunded: full refund\n    }\n    state fulfillment {\n        unfulfilled --> partial: partial fulfillment\n        unfulfilled --> fulfilled: full fulfillment\n        partial --> fulfilled: remaining items fulfilled\n    }\n```\n\n---\n\n### Test 4.1: Shows the order list with seeded orders\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n\n**Expected Results:**\n- Page displays \"#1001\"\n- No JavaScript errors\n\n---\n\n### Test 4.2: Can filter orders by status\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click the \"Paid\" status filter/tab\n5. Verify \"#1001\" is visible\n6. Click the \"Fulfilled\" status filter/tab\n7. Click the \"All\" tab to reset the filter\n\n**Expected Results:**\n- After step 4: \"#1001\" is visible, no JavaScript errors\n- After step 6: filtered results shown, no JavaScript errors\n- After step 7: full list shown, no JavaScript errors\n\n---\n\n### Test 4.3: Shows order detail with line items and totals\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Paid\" (financial status)\n- Page displays \"Unfulfilled\" (fulfillment status)\n- Page displays \"Classic Cotton T-Shirt\" (line item)\n- Page displays \"Subtotal\", \"Shipping\", \"Tax\", and \"Total\" sections\n- No JavaScript errors\n\n---\n\n### Test 4.4: Shows order timeline events\n\n**Preconditions:** Logged in as admin, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"Timeline\" section\n- Timeline shows at least the order creation event\n- No JavaScript errors\n\n---\n\n### Test 4.5: Can create a fulfillment\n\n**Preconditions:** Logged in as admin, Order #1001 is unfulfilled\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Create fulfillment\"\n6. Enter tracking company: `DHL`\n7. Enter tracking number: `DHL123456789`\n8. Click \"Fulfill items\"\n\n**Expected Results:**\n- Page displays \"Fulfillment created\"\n- Page displays \"DHL\" and \"DHL123456789\" in the fulfillment section\n- No JavaScript errors\n\n---\n\n### Test 4.6: Can process a refund\n\n**Preconditions:** Logged in as admin, Order #1001 is paid\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Refund\"\n6. Enter amount: `10.00`\n7. Enter reason: `Customer requested partial refund`\n8. Click \"Process refund\"\n\n**Expected Results:**\n- Page displays \"Refund processed\"\n- Page displays \"Partially refunded\" (updated financial status)\n- No JavaScript errors\n\n---\n\n### Test 4.7: Shows customer information in order detail\n\n**Preconditions:** Logged in as admin, Order #1001 exists with customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n\n**Expected Results:**\n- Page displays \"customer@acme.test\" in the customer info section\n- No JavaScript errors\n\n---\n\n### Test 4.8: Can confirm bank transfer payment\n\n**Preconditions:** Logged in as admin, Order #1005 exists with payment_method=bank_transfer and financial_status=pending\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n5. Verify \"Pending\" financial status badge is displayed\n6. Verify \"Confirm payment\" button is visible\n7. Click \"Confirm payment\"\n\n**Expected Results:**\n- Page displays \"Payment confirmed\"\n- Financial status badge changes to \"Paid\"\n- \"Confirm payment\" button is no longer visible\n- No JavaScript errors\n\n---\n\n### Test 4.9: Shows fulfillment guard for unpaid order\n\n**Preconditions:** Logged in as admin, Order #1005 exists with financial_status=pending (before payment confirmation)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1005\" to open the order detail\n\n**Expected Results:**\n- Page displays a warning message about payment required before fulfillment\n- \"Create fulfillment\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 4.10: Can mark fulfillment as shipped\n\n**Preconditions:** Logged in as admin, Order #1001 is paid and has been fulfilled (fulfillment created in test 4.5)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Create a fulfillment first (if not already done via test 4.5)\n6. Click \"Mark as shipped\" on the fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Shipped\"\n- No JavaScript errors\n\n---\n\n### Test 4.11: Can mark fulfillment as delivered\n\n**Preconditions:** Logged in as admin, Order #1001 has a shipped fulfillment\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Orders\" in the sidebar\n4. Click \"#1001\" to open the order detail\n5. Click \"Mark as delivered\" on the shipped fulfillment card\n\n**Expected Results:**\n- Fulfillment status badge changes to \"Delivered\"\n- Order fulfillment status updates to \"Fulfilled\"\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 5: Admin Discount Management\n\n**File:** `tests/Browser/Admin/DiscountManagementTest.php`\n\n**Purpose:** Verify discount code listing, creation (all three value types), editing, and status display.\n\n### Discount Types\n\n| Value Type | Description | Fields Required |\n|------------|-------------|-----------------|\n| `percent` | Percentage off the order subtotal | code, value (whole number, e.g., 25 for 25%), starts_at, optional ends_at |\n| `fixed` | Fixed currency amount off | code, value (e.g., 10.00), starts_at, optional ends_at |\n| `free_shipping` | Waive shipping costs entirely | code, starts_at, optional ends_at |\n\n---\n\n### Test 5.1: Shows seeded discount codes\n\n**Preconditions:** Logged in as admin, seeded discounts\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"WELCOME10\"\n- Page displays \"FLAT5\"\n- Page displays \"FREESHIP\"\n- No JavaScript errors\n\n---\n\n### Test 5.2: Can create a new percentage discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"Create discount\"\n5. Enter code: `E2ETEST25`\n6. Click \"Percentage\" to select the value type\n7. Enter value: `25`\n8. Enter starts_at: `2026-01-01`\n9. Enter ends_at: `2026-12-31`\n10. Click \"Save\"\n11. Click \"Discounts\" to return to the list\n\n**Expected Results:**\n- After step 10: page displays \"Discount saved\", no JavaScript errors\n- After step 11: page displays \"E2ETEST25\" in the list\n\n---\n\n### Test 5.3: Can create a fixed amount discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFLAT10`\n5. Click \"Fixed amount\" to select the value type\n6. Enter value: `10.00`\n7. Enter starts_at: `2026-01-01`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.4: Can create a free shipping discount code\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" then \"Create discount\"\n4. Enter code: `E2EFREESHIP`\n5. Click \"Free shipping\" to select the value type\n6. Enter starts_at: `2026-01-01`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.5: Can edit a discount\n\n**Preconditions:** Logged in as admin, WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n4. Click \"WELCOME10\" to open the edit form\n5. Change the value field to: `15`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Discount saved\"\n- No JavaScript errors\n\n---\n\n### Test 5.6: Shows discount status indicators\n\n**Preconditions:** Logged in as admin, seeded discounts (active and expired)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Discounts\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Active\" status badge (for active discounts)\n- Page displays \"Expired\" status badge (for EXPIRED20)\n- No JavaScript errors\n\n**Total: 6 tests**\n\n---\n\n## Suite 6: Admin Settings\n\n**File:** `tests/Browser/Admin/SettingsTest.php`\n\n**Purpose:** Verify store settings management across all settings tabs: General, Domains, Shipping, Taxes, Checkout, and Notifications.\n\n### Settings Page Structure\n\nThe admin settings page at `/admin/settings` uses a tabbed interface:\n\n| Tab | Route | Content |\n|-----|-------|---------|\n| General | `/admin/settings` | Store name, contact email, currency, locale |\n| Domains | (tab within settings) | Domain list including `acme-fashion.test` |\n| Shipping | `/admin/settings/shipping` | Shipping zones, rates per zone |\n| Taxes | `/admin/settings/taxes` | Tax mode (manual/provider), prices_include_tax toggle |\n| Checkout | `/admin/settings/checkout` | Checkout behavior settings |\n| Notifications | `/admin/settings/notifications` | Email notification preferences |\n\n> **Note:** Domains tab is covered by Test 6.7. Checkout and Notifications tabs follow the same tabbed navigation pattern; E2E coverage for these tabs should be added when their functionality is implemented.\n\n---\n\n### Test 6.1: Can view store settings\n\n**Preconditions:** Logged in as admin, Store 1 exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Store Settings\"\n- Page displays \"Acme Fashion\"\n- No JavaScript errors\n\n---\n\n### Test 6.2: Can update store name\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Update the name field to: `Acme Fashion Updated`\n5. Click \"Save\"\n6. Click \"Settings\" again to reload the page\n\n**Expected Results:**\n- After step 5: page displays \"Settings saved\", no JavaScript errors\n- After step 6: page displays \"Acme Fashion Updated\" (change persisted)\n\n---\n\n### Test 6.3: Can view shipping zones\n\n**Preconditions:** Logged in as admin, seeded shipping zones\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n\n**Expected Results:**\n- Page displays \"Domestic\"\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\"\n- No JavaScript errors\n\n---\n\n### Test 6.4: Can add a new shipping rate to existing zone\n\n**Preconditions:** Logged in as admin, Domestic shipping zone exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Shipping\" tab\n5. Click \"Add rate\" within the Domestic zone section\n6. Enter name: `Overnight Shipping`\n7. Enter price: `14.99`\n8. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Shipping rate saved\"\n- Page displays \"Overnight Shipping\" and \"14.99\" in the zone\n- No JavaScript errors\n\n---\n\n### Test 6.5: Can view tax settings\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n\n**Expected Results:**\n- Page displays \"Tax Settings\"\n- No JavaScript errors\n\n---\n\n### Test 6.6: Can update tax inclusion setting\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Taxes\" tab\n5. Click/toggle the \"Prices include tax\" switch\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Tax settings saved\"\n- No JavaScript errors\n\n---\n\n### Test 6.7: Can view domain settings\n\n**Preconditions:** Logged in as admin, Store 1 with domain configured\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Settings\" in the sidebar\n4. Click \"Domains\" tab\n\n**Expected Results:**\n- Page displays \"acme-fashion.test\"\n- No JavaScript errors\n\n**Total: 7 tests**\n\n---\n\n## Suite 7: Storefront Browsing\n\n**File:** `tests/Browser/Storefront/BrowsingTest.php`\n\n**Purpose:** Verify all storefront pages render correctly as a guest visitor - home page, collection listings, product detail with variant options, search results, static pages, and content visibility rules (draft/stock).\n\n### Storefront Page Map\n\n```mermaid\nflowchart LR\n    Home[\"/\"] --> Collections[\"/collections\"]\n    Home --> Product[\"/products/{handle}\"]\n    Collections --> CollectionDetail[\"/collections/{handle}\"]\n    CollectionDetail --> Product\n    Home --> Search[\"/search?q=...\"]\n    Search --> Product\n    Home --> Pages[\"/pages/{handle}\"]\n    Product --> Cart[\"/cart\"]\n```\n\n---\n\n### Test 7.1: Shows featured products on home page\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 7.2: Shows collection with product grid\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"Classic Cotton T-Shirt\"\n- No JavaScript errors\n\n---\n\n### Test 7.3: Can navigate from collection to product\n\n**Preconditions:** Seeded collections and products\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n2. Click \"Classic Cotton T-Shirt\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Add to cart\"\n- No JavaScript errors\n\n---\n\n### Test 7.4: Shows product detail with variant options\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- Page displays \"Size\"\n- Page displays \"Color\"\n- No JavaScript errors\n\n---\n\n### Test 7.5: Shows size and color option values\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays size options: \"S\", \"M\", \"L\", \"XL\"\n- Page displays color options: \"Black\", \"White\", \"Navy\"\n- No JavaScript errors\n\n---\n\n### Test 7.6: Updates price when variant changes on product with compare-at pricing\n\n**Preconditions:** Product #2 with compare-at pricing\n\n**Steps:**\n1. Navigate to `/products/premium-slim-fit-jeans`\n2. Select a variant that has a compare-at price (sale variant)\n\n**Expected Results:**\n- Page displays \"Premium Slim Fit Jeans\"\n- Sale price is displayed\n- Compare-at (original) price is shown with strikethrough styling\n- No JavaScript errors\n\n---\n\n### Test 7.7: Shows search results for valid query\n\n**Preconditions:** Seeded products\n\n**Steps:**\n1. Navigate to `/search?q=cotton`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\" in results\n- No JavaScript errors\n\n---\n\n### Test 7.8: Shows no results message for invalid query\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=zznonexistentproductzz`\n\n**Expected Results:**\n- Page displays \"No results\"\n- No JavaScript errors\n\n---\n\n### Test 7.9: Does not show draft products on storefront collections\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/collections`\n\n**Expected Results:**\n- Product #15 (draft) must NOT appear anywhere in the listing\n- No JavaScript errors\n\n---\n\n### Test 7.10: Does not show draft products in search results\n\n**Preconditions:** Product #15 with draft status\n\n**Steps:**\n1. Navigate to `/search?q=draft`\n\n**Expected Results:**\n- No draft products appear in search results\n- No JavaScript errors\n\n---\n\n### Test 7.11: Shows out of stock messaging for deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to `/products/limited-edition-sneakers`\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden\n- No JavaScript errors\n\n---\n\n### Test 7.12: Shows backorder messaging for continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to `/products/backorder-denim-jacket`\n\n**Expected Results:**\n- Page displays \"Available on backorder\"\n- \"Add to cart\" button is still enabled\n- No JavaScript errors\n\n---\n\n### Test 7.13: Shows new arrivals collection\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/new-arrivals`\n\n**Expected Results:**\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 7.14: Shows static about page\n\n**Preconditions:** Seeded pages\n\n**Steps:**\n1. Navigate to `/pages/about`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 7.15: Navigates between pages using the main navigation\n\n**Preconditions:** Seeded store with navigation\n\n**Steps:**\n1. Navigate to `/`\n2. Click \"T-Shirts\" in the main navigation menu\n\n**Expected Results:**\n- Page navigates to the T-Shirts collection\n- Page displays \"T-Shirts\"\n- No JavaScript errors\n\n**Total: 15 tests**\n\n---\n\n## Suite 8: Cart Flow\n\n**File:** `tests/Browser/Storefront/CartTest.php`\n\n**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.\n\n### Cart Interaction Flow\n\n```mermaid\nflowchart TD\n    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]\n    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]\n    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]\n    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]\n    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}\n    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]\n    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]\n    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]\n    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]\n    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]\n```\n\n### Discount Code Validation Matrix\n\n| Code | Type | Value | Min Purchase | Status | Expected Behavior |\n|------|------|-------|-------------|--------|-------------------|\n| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |\n| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |\n| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |\n| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with \"expired\" error |\n| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with \"usage limit\" error |\n| INVALID | n/a | n/a | n/a | does not exist | Rejected with \"Invalid discount code\" error |\n\n---\n\n### Test 8.1: Can add product to cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.2: Can view cart with added item\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Your Cart\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"24.99\"\n- No JavaScript errors\n\n---\n\n### Test 8.3: Can update quantity in cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"+\" to increment quantity\n\n**Expected Results:**\n- Quantity updated to 2\n- Line total updates to 49.98 (2 x 24.99)\n- Page displays \"49.98\"\n- No JavaScript errors\n\n---\n\n### Test 8.4: Can remove item from cart\n\n**Preconditions:** Product #1 exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"Classic Cotton T-Shirt\" is visible\n7. Click \"Remove\" on the item\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- No JavaScript errors\n\n---\n\n### Test 8.5: Can add multiple different products\n\n**Preconditions:** Products #1 and #2 exist\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/products/premium-slim-fit-jeans`\n6. Click \"Add to cart\"\n7. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- Page displays \"Premium Slim Fit Jeans\"\n- No JavaScript errors\n\n---\n\n### Test 8.6: Can apply valid discount code WELCOME10\n\n**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Verify \"24.99\" is displayed\n7. Enter discount code: `WELCOME10` in the discount_code field\n8. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"WELCOME10\" label\n- Page displays \"Discount\" line in totals\n- 10% discount applied (approximately 2.50 off 24.99)\n- No JavaScript errors\n\n---\n\n### Test 8.7: Shows error for invalid discount code\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `INVALID` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"Invalid discount code\"\n- No JavaScript errors\n\n---\n\n### Test 8.8: Shows error for expired discount code\n\n**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `EXPIRED20` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"expired\"\n- No JavaScript errors\n\n---\n\n### Test 8.9: Shows error for maxed out discount code\n\n**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `MAXED` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays error message containing \"usage limit\"\n- No JavaScript errors\n\n---\n\n### Test 8.10: Can apply free shipping discount\n\n**Preconditions:** Product #1 in cart, FREESHIP discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FREESHIP` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FREESHIP\" label\n- Page displays \"Free shipping\" indicator\n- No JavaScript errors\n\n---\n\n### Test 8.11: Can apply FLAT5 discount for fixed amount off\n\n**Preconditions:** Product #1 in cart, FLAT5 discount exists\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label\n- Page displays \"5.00\" discount amount in totals\n- No JavaScript errors\n\n---\n\n### Test 8.12: Shows subtotal and total in cart\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Subtotal\" label\n- Page displays \"24.99\"\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 9: Checkout Flow\n\n**File:** `tests/Browser/Storefront/CheckoutTest.php`\n\n**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.\n\n### Checkout Step Flow\n\n```mermaid\nflowchart TD\n    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]\n    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]\n    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]\n    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]\n    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]\n    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation\n    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]\n\n    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]\n    Step1 -->|empty email| Step1Error[Validation error on email]\n    Step2 -->|missing fields| Step2Error[Validation errors on address fields]\n    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]\n\n    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]\n    Step2 -->|Country = US| InternationalRates[International rates]\n\n    Cart -->|Empty cart| EmptyState[Checkout disabled]\n```\n\n### Checkout Step Detail\n\n| Step | Section | Required Fields | Visible After Completion |\n|------|---------|-----------------|--------------------------|\n| 1. Contact | Contact section | email | Email summary shown |\n| 2. Shipping Address | Address section | first_name, last_name, address1, city, postal_code, country (select) | Address summary shown |\n| 3. Shipping Method | Shipping section | Select one of the available shipping rates | Selected method + price shown |\n| 4. Payment | Payment section | Select payment method (Credit Card / PayPal / Bank Transfer), fill card details if credit card | Redirect to confirmation |\n| 5. Confirmation | Confirmation page | n/a (read only) | \"Thank you\", order number, line items, totals. Bank transfer: also shows transfer instructions. |\n\n### Totals Calculation Reference\n\nFor the happy path (Classic Cotton T-Shirt, DE address, Standard Shipping):\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Shipping (Standard, Domestic DE) | 4.99 EUR |\n| Tax (included in price for DE, manual mode) | varies |\n| **Total** | **29.98 EUR** |\n\nWith FLAT5 discount applied:\n\n| Line | Amount |\n|------|--------|\n| Subtotal | 24.99 EUR |\n| Discount (FLAT5) | -5.00 EUR |\n| Shipping | 4.99 EUR |\n| **Total** | **24.98 EUR** |\n\n---\n\n### Test 9.1: Completes full checkout with credit card\n\n**Preconditions:** Product #1, Domestic shipping zone with Standard Shipping at 4.99 EUR\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test-buyer@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `Buyer`\n11. Enter address: `Teststrasse 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `10115`\n14. Enter country: `DE`\n15. Click \"Continue\"\n16. Verify \"Standard Shipping\" and \"4.99\" are displayed\n17. Click \"Standard Shipping\" to select it\n18. Click \"Continue\"\n19. Verify \"Credit Card\" is pre-selected as payment method\n20. Enter card number: `4242 4242 4242 4242`\n21. Enter cardholder name: `Test Buyer`\n22. Enter expiry: `12/28`\n23. Enter CVC: `123`\n24. Verify total is 29.98 (24.99 + 4.99 shipping)\n25. Click \"Pay now\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays an order number (prefixed with \"#\")\n- No JavaScript errors\n\n---\n\n### Test 9.2: Shows shipping methods based on German address\n\n**Preconditions:** Product #1 in cart, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Hans`\n10. Enter last name: `Mueller`\n11. Enter address: `Berliner Str. 10`\n12. Enter city: `Munich`\n13. Enter postal code: `80331`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- Page displays \"4.99\" (Domestic zone rate)\n- No JavaScript errors\n\n---\n\n### Test 9.3: Shows international shipping methods for non-DE address\n\n**Preconditions:** Product #1 in cart, International shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `John`\n10. Enter last name: `Smith`\n11. Enter address: `123 Main St`\n12. Enter city: `New York`\n13. Enter postal code: `10001`\n14. Enter country: `US`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"International\" shipping rates (not Domestic rates)\n- No JavaScript errors\n\n---\n\n### Test 9.4: Applies discount during checkout\n\n**Preconditions:** Product #1, FLAT5 discount, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Enter discount code: `FLAT5` in the discount_code field\n7. Click \"Apply\"\n8. Verify \"FLAT5\" is displayed\n9. Click \"Checkout\"\n10. Enter email: `test@example.com`\n11. Click \"Continue\"\n12. Enter first name: `Test`\n13. Enter last name: `User`\n14. Enter address: `Teststr 1`\n15. Enter city: `Berlin`\n16. Enter postal code: `10115`\n17. Enter country: `DE`\n18. Click \"Continue\"\n19. Click \"Standard Shipping\" to select it\n20. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"FLAT5\" label in checkout totals\n- Page displays \"5.00\" discount amount\n- Totals reflect: 24.99 - 5.00 + 4.99 = 24.98\n- No JavaScript errors\n\n---\n\n### Test 9.5: Validates required contact email\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without entering an email\n\n**Expected Results:**\n- Validation error referencing \"email\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.6: Validates required shipping address fields\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Click \"Continue\" without filling any address fields\n\n**Expected Results:**\n- Validation errors for required fields (first_name, last_name, address1, city, postal_code, country) are displayed\n- No JavaScript errors\n\n---\n\n### Test 9.7: Validates invalid postal code format\n\n**Preconditions:** Product #1 in cart\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Enter email: `test@example.com`\n8. Click \"Continue\"\n9. Enter first name: `Test`\n10. Enter last name: `User`\n11. Enter address: `Teststr 1`\n12. Enter city: `Berlin`\n13. Enter postal code: `INVALID`\n14. Enter country: `DE`\n15. Click \"Continue\"\n\n**Expected Results:**\n- Validation error for postal code format is displayed\n- No JavaScript errors\n\n---\n\n### Test 9.8: Prevents checkout with empty cart\n\n**Preconditions:** No products in cart\n\n**Steps:**\n1. Navigate to `/cart` (without adding any items)\n\n**Expected Results:**\n- Page displays \"Your cart is empty\"\n- \"Checkout\" button is disabled or not shown\n- No JavaScript errors\n\n---\n\n### Test 9.9: Completes checkout with PayPal\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"PayPal\" radio button to select PayPal as payment method\n9. Click \"Pay with PayPal\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays \"PayPal\" in payment method section\n- No JavaScript errors\n\n---\n\n### Test 9.10: Completes checkout with bank transfer\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Click \"Bank Transfer\" radio button to select Bank Transfer as payment method\n9. Click \"Place order\"\n\n**Expected Results:**\n- Confirmation page displays \"Thank you\"\n- Confirmation page displays bank transfer instructions (IBAN, BIC, reference)\n- Confirmation page displays the order number as reference\n- No JavaScript errors\n\n---\n\n### Test 9.11: Shows error for declined credit card (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Verify \"Credit Card\" is selected\n9. Enter card number: `4000 0000 0000 0002` (magic decline card)\n10. Enter cardholder name: `Test Buyer`\n11. Enter expiry: `12/28`\n12. Enter CVC: `123`\n13. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"declined\"\n- Customer remains on the checkout page (NOT redirected to confirmation)\n- No JavaScript errors\n\n---\n\n### Test 9.12: Shows error for insufficient funds (magic number)\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M, click \"Black\" to select color Black\n3. Click \"Add to cart\"\n4. Navigate to `/cart`, click \"Checkout\"\n5. Enter email: `test@example.com`, click \"Continue\"\n6. Fill shipping address (DE), click \"Continue\"\n7. Select \"Standard Shipping\", click \"Continue\"\n8. Enter card number: `4000 0000 0000 9995` (magic insufficient funds card)\n9. Enter cardholder name: `Test Buyer`\n10. Enter expiry: `12/28`\n11. Enter CVC: `123`\n12. Click \"Pay now\"\n\n**Expected Results:**\n- Page displays an error message containing \"insufficient\"\n- Customer remains on the checkout page\n- No JavaScript errors\n\n---\n\n### Test 9.13: Switches between payment method forms\n\n**Preconditions:** Product #1, reached Step 4 (payment) in checkout\n\n**Steps:**\n1. Complete checkout steps 1-3 (contact, address, shipping)\n2. Verify \"Credit Card\" is selected and card form fields are visible\n3. Click \"PayPal\" radio button\n4. Verify card form fields are hidden\n5. Verify \"Pay with PayPal\" button is visible\n6. Click \"Bank Transfer\" radio button\n7. Verify \"Place order\" button is visible\n8. Verify bank transfer information text is displayed\n\n**Expected Results:**\n- Payment form dynamically updates based on selected payment method\n- No JavaScript errors\n\n**Total: 13 tests**\n\n---\n\n## Suite 10: Customer Account\n\n**File:** `tests/Browser/Storefront/CustomerAccountTest.php`\n\n**Purpose:** Verify customer registration, login, account dashboard, order history, order detail, address management, and logout on the storefront.\n\n### Customer Account Flow\n\n```mermaid\nflowchart TD\n    Guest[Guest visitor] -->|visit /account/register| Register[Registration Form]\n    Guest -->|visit /account/login| Login[Login Form]\n    Register -->|valid data + click 'Create account'| Dashboard[My Account Dashboard]\n    Login -->|valid credentials + click 'Log in'| Dashboard\n    Login -->|invalid credentials| Login\n    Dashboard --> OrderHistory[/account/orders]\n    OrderHistory --> OrderDetail[/account/orders/number]\n    Dashboard --> Addresses[/account/addresses]\n    Addresses -->|click 'Add address'| AddAddress[Address form]\n    Addresses -->|click 'Edit'| EditAddress[Edit address form]\n    Dashboard -->|click 'Logout'| Guest\n```\n\n---\n\n### Test 10.1: Can register a new customer\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `New Customer`\n3. Enter email: `new-customer-e2e@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- No JavaScript errors\n\n---\n\n### Test 10.2: Shows validation errors for duplicate email registration\n\n**Preconditions:** customer@acme.test already exists\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Duplicate Customer`\n3. Enter email: `customer@acme.test`\n4. Enter password: `password123`\n5. Enter password confirmation: `password123`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Page displays \"already been taken\" error\n- No JavaScript errors\n\n---\n\n### Test 10.3: Shows validation errors for mismatched passwords\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/register`\n2. Enter name: `Test Customer`\n3. Enter email: `mismatch@example.com`\n4. Enter password: `password123`\n5. Enter password confirmation: `different456`\n6. Click \"Create account\"\n\n**Expected Results:**\n- Validation error referencing \"password\" is displayed\n- No JavaScript errors\n\n---\n\n### Test 10.4: Can log in as existing customer\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"My Account\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 10.5: Shows error for invalid customer credentials\n\n**Preconditions:** customer@acme.test seeded\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `wrongpassword`\n4. Click \"Log in\"\n\n**Expected Results:**\n- Page displays \"Invalid credentials\"\n- No JavaScript errors\n\n---\n\n### Test 10.6: Redirects unauthenticated customers to login\n\n**Preconditions:** Not logged in\n\n**Steps:**\n1. Navigate to `/account` (without logging in)\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login form)\n- No JavaScript errors\n\n---\n\n### Test 10.7: Shows order history for logged-in customer\n\n**Preconditions:** Logged in as customer@acme.test, Orders #1001/#1002/#1004 exist\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\" or navigate to order history\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"#1002\"\n- Page displays \"#1004\"\n- No JavaScript errors\n\n---\n\n### Test 10.8: Shows order detail for customer order\n\n**Preconditions:** Logged in as customer, Order #1001 exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Orders\"\n4. Click \"#1001\"\n\n**Expected Results:**\n- Page displays \"#1001\"\n- Page displays \"Subtotal\"\n- Page displays \"Total\"\n- No JavaScript errors\n\n---\n\n### Test 10.9: Can view addresses\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n\n**Expected Results:**\n- Existing addresses are displayed\n- No JavaScript errors\n\n---\n\n### Test 10.10: Can add a new address\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Add address\"\n5. Enter first name: `John`\n6. Enter last name: `Doe`\n7. Enter address: `New Street 42`\n8. Enter city: `Hamburg`\n9. Enter postal code: `20095`\n10. Enter country: `DE`\n11. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"New Street 42\"\n- Page displays \"Hamburg\"\n- No JavaScript errors\n\n---\n\n### Test 10.11: Can edit an existing address\n\n**Preconditions:** Logged in as customer, at least one address exists\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Log in as customer (email: `customer@acme.test`, password: `password`)\n3. Click \"Addresses\"\n4. Click \"Edit\" on the first address\n5. Change city to: `Frankfurt`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Address saved\"\n- Page displays \"Frankfurt\"\n- No JavaScript errors\n\n---\n\n### Test 10.12: Can log out\n\n**Preconditions:** Logged in as customer\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Verify \"My Account\" is displayed\n6. Click \"Logout\"\n\n**Expected Results:**\n- Page displays \"Log in\" (redirected to login or home page)\n- No JavaScript errors\n\n**Total: 12 tests**\n\n---\n\n## Suite 11: Inventory Enforcement\n\n**File:** `tests/Browser/Storefront/InventoryTest.php`\n\n**Purpose:** Verify that inventory policies are correctly enforced on the storefront - deny-policy blocks purchasing when out of stock, continue-policy allows backorders.\n\n### Inventory Policy Decision Matrix\n\n| Stock Level | Policy | Storefront Display | Add-to-Cart |\n|-------------|--------|--------------------|-------------|\n| > 0 | `deny` | \"Add to cart\" button enabled | Allowed |\n| > 0 | `continue` | \"Add to cart\" button enabled | Allowed |\n| 0 | `deny` | \"Sold out\" text, button disabled | Blocked |\n| 0 | `continue` | \"Available on backorder\" text, button enabled | Allowed |\n\n---\n\n### Test 11.1: Blocks add-to-cart for out-of-stock deny-policy product\n\n**Preconditions:** Product #17 (0 stock, inventory policy = deny)\n\n**Steps:**\n1. Navigate to the product page for Product #17 (use actual handle from seeder)\n\n**Expected Results:**\n- Page displays \"Sold out\"\n- \"Add to cart\" button is disabled or hidden (not clickable)\n- No JavaScript errors\n\n---\n\n### Test 11.2: Allows add-to-cart for out-of-stock continue-policy product\n\n**Preconditions:** Product #18 (0 stock, inventory policy = continue)\n\n**Steps:**\n1. Navigate to the product page for Product #18 (use actual handle from seeder)\n2. Verify \"Available on backorder\" is displayed\n3. Click \"Add to cart\"\n4. Navigate to `/cart`\n\n**Expected Results:**\n- \"Available on backorder\" is visible on the product page\n- Add to cart succeeds\n- Cart page shows the product\n- No JavaScript errors\n\n---\n\n### Test 11.3: Shows correct stock status for in-stock product\n\n**Preconditions:** Product #1 (in stock)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Add to cart\" button (enabled)\n- \"Sold out\" is NOT displayed\n- \"Available on backorder\" is NOT displayed\n- No JavaScript errors\n\n---\n\n### Test 11.4: Prevents adding more than available stock for deny-policy product\n\n**Preconditions:** A product with limited stock (deny policy)\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Attempt to increase quantity beyond the available stock level\n\n**Expected Results:**\n- System prevents exceeding available inventory (either caps quantity or shows error)\n- No JavaScript errors\n\n**Total: 4 tests**\n\n---\n\n## Suite 12: Tenant Isolation\n\n**File:** `tests/Browser/Storefront/TenantIsolationTest.php`\n\n**Purpose:** Verify that multi-store tenant isolation is enforced - data from one store never leaks into another store's storefront or admin panel.\n\n### Isolation Requirements\n\n```mermaid\nflowchart LR\n    subgraph Store1[Store 1 - Acme Fashion]\n        S1Products[Fashion Products]\n        S1Orders[Fashion Orders]\n        S1Customers[Fashion Customers]\n    end\n    subgraph Store2[Store 2 - Different Store]\n        S2Products[Other Products]\n        S2Orders[Other Orders]\n        S2Customers[Other Customers]\n    end\n    S1Products -.-x S2Products\n    S1Orders -.-x S2Orders\n    S1Customers -.-x S2Customers\n```\n\nDashed X lines indicate these must NEVER cross. No data from Store 2 should ever appear when browsing Store 1, and vice versa.\n\n---\n\n### Test 12.1: Store 1 only shows Store 1 products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/` (resolved to Store 1 via acme-fashion.test domain)\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Page displays \"Classic Cotton T-Shirt\"\n- Products from other stores (e.g., electronics from Store 2) are absent\n- No JavaScript errors\n\n---\n\n### Test 12.2: Store 1 collections only contain Store 1 products\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Only Store 1 products appear in the collection grid\n- No JavaScript errors\n\n---\n\n### Test 12.3: Admin cannot access other store data\n\n**Preconditions:** Logged in as admin@acme.test (Store 1 owner)\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Products\" in the sidebar\n4. Review the product list\n5. Click \"Orders\" in the sidebar\n6. Review the order list\n\n**Expected Results:**\n- All products listed belong to Store 1 only\n- All orders listed belong to Store 1 only\n- No cross-store data is visible\n- No JavaScript errors\n\n---\n\n### Test 12.4: Search only returns current store products\n\n**Preconditions:** Seeded Store 1 products\n\n**Steps:**\n1. Navigate to `/search?q=product`\n\n**Expected Results:**\n- Only Store 1 products appear in search results\n- No cross-store data leakage\n- No JavaScript errors\n\n---\n\n### Test 12.5: Customer accounts are scoped to their store\n\n**Preconditions:** customer@acme.test seeded for Store 1\n\n**Steps:**\n1. Navigate to `/account/login`\n2. Enter email: `customer@acme.test`\n3. Enter password: `password`\n4. Click \"Log in\"\n5. Click \"Orders\"\n\n**Expected Results:**\n- Customer only sees Store 1 orders (#1001, #1002, #1004)\n- No JavaScript errors\n\n**Total: 5 tests**\n\n---\n\n## Suite 13: Responsive / Mobile\n\n**File:** `tests/Browser/Storefront/ResponsiveTest.php`\n\n**Purpose:** Verify that the storefront and admin panel render and function correctly on mobile (375x812) and tablet (768x1024) viewports.\n\n### Viewport Definitions\n\n| Device | Width | Height | Use For |\n|--------|-------|--------|---------|\n| iPhone (mobile) | 375 | 812 | Storefront tests |\n| iPad (tablet) | 768 | 1024 | Admin panel tests |\n\n---\n\n### Test 13.1: Storefront home works on mobile viewport\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Set viewport to iPhone dimensions (375x812)\n2. Navigate to `/`\n\n**Expected Results:**\n- Page displays \"Acme Fashion\"\n- Mobile menu (hamburger) is visible\n- Desktop navigation is hidden\n- No horizontal scroll\n- No JavaScript errors\n\n---\n\n### Test 13.2: Product page stacks layout on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\", \"24.99\", \"Add to cart\"\n- Gallery and details stack vertically\n- No JavaScript errors\n\n---\n\n### Test 13.3: Can add to cart on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n\n**Expected Results:**\n- Product added to cart successfully\n- No JavaScript errors\n\n---\n\n### Test 13.4: Cart page works on mobile\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n\n**Expected Results:**\n- Page displays \"Classic Cotton T-Shirt\"\n- \"Checkout\" button is visible and accessible\n- No JavaScript errors\n\n---\n\n### Test 13.5: Checkout flow works on mobile\n\n**Preconditions:** Product #1, Domestic shipping zone\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/products/classic-cotton-t-shirt`\n3. Click \"M\" to select size M\n4. Click \"Black\" to select color Black\n5. Click \"Add to cart\"\n6. Navigate to `/cart`\n7. Click \"Checkout\"\n8. Enter email: `mobile@example.com`\n9. Click \"Continue\"\n10. Enter first name: `Mobile`\n11. Enter last name: `User`\n12. Enter address: `Mobile Str 1`\n13. Enter city: `Berlin`\n14. Enter postal code: `10115`\n15. Enter country: `DE`\n16. Click \"Continue\"\n\n**Expected Results:**\n- Page displays \"Standard Shipping\"\n- All steps accessible without horizontal scrolling\n- No JavaScript errors\n\n---\n\n### Test 13.6: Admin login works on tablet viewport\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Enter email: `admin@acme.test`\n4. Enter password: `password`\n5. Click \"Sign in\"\n\n**Expected Results:**\n- Page displays \"Dashboard\"\n- No JavaScript errors\n\n---\n\n### Test 13.7: Admin sidebar navigation works on tablet\n\n**Preconditions:** Seeded admin user\n\n**Steps:**\n1. Set viewport to iPad dimensions (768x1024)\n2. Navigate to `/admin/login`\n3. Log in as admin (email: `admin@acme.test`, password: `password`)\n4. Click \"Products\" in the sidebar\n5. Verify \"Products\" heading is visible\n6. Click \"Orders\" in the sidebar\n7. Verify \"Orders\" heading is visible\n\n**Expected Results:**\n- Each section loads correctly\n- No JavaScript errors\n\n---\n\n### Test 13.8: Collection page works on mobile with filters\n\n**Preconditions:** Seeded collections\n\n**Steps:**\n1. Set viewport to mobile (375x812)\n2. Navigate to `/collections/t-shirts`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Products are visible\n- Filters are accessible (may be behind a \"Filter\" button on mobile)\n- No JavaScript errors\n\n**Total: 8 tests**\n\n---\n\n## Suite 14: Accessibility\n\n**File:** `tests/Browser/Storefront/AccessibilityTest.php`\n\n**Purpose:** Verify that the storefront meets basic accessibility standards - no JS console errors/warnings, proper heading hierarchy, form labels, ARIA attributes, and keyboard navigability.\n\n### Accessibility Requirements Checklist\n\n| Requirement | What To Verify |\n|-------------|----------------|\n| No JS errors | assertNoJavascriptErrors on every page |\n| No console warnings | assertNoConsoleLogs on key pages |\n| Heading hierarchy | One h1 per page, headings in logical order |\n| Form labels | Every input has an associated label or aria-label |\n| Image alt text | Product images have meaningful alt attributes |\n| Keyboard navigation | Interactive elements reachable via Tab key; focus indicators visible |\n| Error accessibility | Validation errors linked to fields via aria-describedby or similar |\n\n---\n\n### Test 14.1: Home page has no JavaScript errors or console warnings\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.2: Home page has proper heading hierarchy\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n\n**Expected Results:**\n- Page has exactly one h1 element\n- \"Acme Fashion\" is visible (h1 content)\n- Headings follow a logical order\n- No JavaScript errors\n\n---\n\n### Test 14.3: Product page has proper ARIA labels for variant selector\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- \"Size\" and \"Color\" labels are visible for variant selectors\n- \"Add to cart\" button is properly labeled\n- No JavaScript errors\n\n---\n\n### Test 14.4: Product page images have alt text\n\n**Preconditions:** Product #1 with images\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n\n**Expected Results:**\n- All product images have non-empty, meaningful alt attributes\n- No JavaScript errors\n\n---\n\n### Test 14.5: Customer login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/account/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.6: Admin login form has accessible labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/admin/login`\n\n**Expected Results:**\n- \"Email\" label is visible and associated with the email input\n- \"Password\" label is visible and associated with the password input\n- No JavaScript errors\n\n---\n\n### Test 14.7: Checkout form has accessible labels\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n\n**Expected Results:**\n- \"Email\" label is visible\n- Form fields have associated labels\n- No JavaScript errors\n\n---\n\n### Test 14.8: Checkout validation errors are accessible\n\n**Preconditions:** Product #1\n\n**Steps:**\n1. Navigate to `/products/classic-cotton-t-shirt`\n2. Click \"M\" to select size M\n3. Click \"Black\" to select color Black\n4. Click \"Add to cart\"\n5. Navigate to `/cart`\n6. Click \"Checkout\"\n7. Click \"Continue\" without filling any fields\n\n**Expected Results:**\n- Validation error referencing \"email\" is visible\n- Errors are linked to their respective fields\n- No JavaScript errors\n\n---\n\n### Test 14.9: Can navigate storefront with keyboard only\n\n**Preconditions:** Seeded store\n\n**Steps:**\n1. Navigate to `/`\n2. Press Tab key repeatedly to move focus through interactive elements\n3. Press Enter on a focused navigation link\n\n**Expected Results:**\n- Focus indicators are visible on interactive elements\n- Navigation works via keyboard (Enter activates links)\n- No JavaScript errors\n\n---\n\n### Test 14.10: Cart page has no console errors or warnings\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/cart`\n\n**Expected Results:**\n- No JavaScript errors\n- No console log warnings\n\n---\n\n### Test 14.11: Search page has proper form labels\n\n**Preconditions:** None\n\n**Steps:**\n1. Navigate to `/search?q=shirt`\n\n**Expected Results:**\n- Search input has a label or aria-label attribute\n- No JavaScript errors\n\n**Total: 11 tests**\n\n---\n\n## Suite 15: Admin Collections Management\n\n**File:** `tests/Browser/Admin/CollectionManagementTest.php`\n\n**Purpose:** Verify collection CRUD operations in the admin panel.\n\n---\n\n### Test 15.1: Shows the collection list with seeded collections\n\n**Preconditions:** Logged in as admin, seeded collections\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/collections`\n\n**Expected Results:**\n- Page displays \"T-Shirts\"\n- Page displays \"New Arrivals\"\n- No JavaScript errors\n\n---\n\n### Test 15.2: Can create a new collection\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"Create collection\"\n5. Enter title: `E2E Test Collection`\n6. Enter description: `A collection created by the E2E test suite.`\n7. Click \"Save\"\n8. Navigate to `/admin/collections`\n\n**Expected Results:**\n- After step 7: page displays \"Collection saved\", no JavaScript errors\n- After step 8: page displays \"E2E Test Collection\" in the list\n\n---\n\n### Test 15.3: Can edit a collection\n\n**Preconditions:** Logged in as admin, T-Shirts collection exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/collections`\n4. Click \"T-Shirts\"\n5. Change description to: `Updated description for T-Shirts collection.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Collection saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 16: Admin Customer Management\n\n**File:** `tests/Browser/Admin/CustomerManagementTest.php`\n\n**Purpose:** Verify customer listing and detail views in the admin panel.\n\n---\n\n### Test 16.1: Shows the customer list\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Customers\" in the sidebar\n\n**Expected Results:**\n- Page displays \"customer@acme.test\"\n- Page displays \"John Doe\"\n- No JavaScript errors\n\n---\n\n### Test 16.2: Shows customer detail with order history\n\n**Preconditions:** Logged in as admin, seeded customer with orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"John Doe\"\n- Page displays \"customer@acme.test\"\n- Page displays \"#1001\" (order history)\n- No JavaScript errors\n\n---\n\n### Test 16.3: Shows customer addresses\n\n**Preconditions:** Logged in as admin, seeded customer\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Customers\" in the sidebar\n4. Click \"John Doe\"\n\n**Expected Results:**\n- Page displays \"Addresses\" section\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 17: Admin Pages Management\n\n**File:** `tests/Browser/Admin/PageManagementTest.php`\n\n**Purpose:** Verify CMS page management in the admin panel.\n\n---\n\n### Test 17.1: Shows the pages list\n\n**Preconditions:** Logged in as admin, seeded pages\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Navigate to `/admin/pages`\n\n**Expected Results:**\n- Page displays \"About\"\n- No JavaScript errors\n\n---\n\n### Test 17.2: Can create a new page\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"Create page\"\n5. Enter title: `FAQ`\n6. Enter body: `Frequently asked questions content here.`\n7. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n---\n\n### Test 17.3: Can edit an existing page\n\n**Preconditions:** Logged in as admin, About page exists\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Navigate to `/admin/pages`\n4. Click \"About\"\n5. Update body to: `Updated about page content.`\n6. Click \"Save\"\n\n**Expected Results:**\n- Page displays \"Page saved\"\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Suite 18: Admin Analytics Dashboard\n\n**File:** `tests/Browser/Admin/AnalyticsTest.php`\n\n**Purpose:** Verify the analytics dashboard renders with data and no errors.\n\n### Analytics Dashboard Sections\n\nThe dashboard at `/admin/analytics` should display:\n\n| Section | Content |\n|---------|---------|\n| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |\n| Sales Chart | Time-series chart of revenue over a date range |\n| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |\n\n---\n\n### Test 18.1: Shows the analytics dashboard\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin (email: `admin@acme.test`, password: `password`)\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Analytics\"\n- No JavaScript errors\n\n---\n\n### Test 18.2: Shows sales data\n\n**Preconditions:** Logged in as admin, seeded orders\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Orders\" KPI label\n- Page displays \"Revenue\" KPI label\n- No JavaScript errors\n\n---\n\n### Test 18.3: Shows conversion funnel data\n\n**Preconditions:** Logged in as admin\n\n**Steps:**\n1. Navigate to `/admin/login`\n2. Log in as admin\n3. Click \"Analytics\" in the sidebar\n\n**Expected Results:**\n- Page displays \"Visits\" label (part of funnel display)\n- No JavaScript errors\n\n**Total: 3 tests**\n\n---\n\n## Test Count Summary\n\n| Suite | File | Tests |\n|-------|------|:-----:|\n| 1 - Smoke Tests | `tests/Browser/SmokeTest.php` | 10 |\n| 2 - Admin Authentication | `tests/Browser/Admin/AuthenticationTest.php` | 10 |\n| 3 - Admin Product Management | `tests/Browser/Admin/ProductManagementTest.php` | 7 |\n| 4 - Admin Order Management | `tests/Browser/Admin/OrderManagementTest.php` | 7 |\n| 5 - Admin Discount Management | `tests/Browser/Admin/DiscountManagementTest.php` | 6 |\n| 6 - Admin Settings | `tests/Browser/Admin/SettingsTest.php` | 7 |\n| 7 - Storefront Browsing | `tests/Browser/Storefront/BrowsingTest.php` | 15 |\n| 8 - Cart Flow | `tests/Browser/Storefront/CartTest.php` | 12 |\n| 9 - Checkout Flow | `tests/Browser/Storefront/CheckoutTest.php` | 8 |\n| 10 - Customer Account | `tests/Browser/Storefront/CustomerAccountTest.php` | 12 |\n| 11 - Inventory Enforcement | `tests/Browser/Storefront/InventoryTest.php` | 4 |\n| 12 - Tenant Isolation | `tests/Browser/Storefront/TenantIsolationTest.php` | 5 |\n| 13 - Responsive / Mobile | `tests/Browser/Storefront/ResponsiveTest.php` | 8 |\n| 14 - Accessibility | `tests/Browser/Storefront/AccessibilityTest.php` | 11 |\n| 15 - Admin Collections | `tests/Browser/Admin/CollectionManagementTest.php` | 3 |\n| 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |\n| 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |\n| 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |\n| **Total** | **18 files** | **134** |\n\n---\n\n## Test Execution Commands\n\n| What | Command |\n|------|---------|\n| Run all browser tests | `php artisan test tests/Browser/` |\n| Run a specific suite | `php artisan test tests/Browser/SmokeTest.php` |\n| Run a specific suite (nested) | `php artisan test tests/Browser/Storefront/CartTest.php` |\n| Run a single test by name | `php artisan test --filter=\"can add product to cart\"` |\n| Run admin auth tests | `php artisan test tests/Browser/Admin/AuthenticationTest.php` |\n| Run checkout flow tests | `php artisan test tests/Browser/Storefront/CheckoutTest.php` |\n\n---\n\n## Implementation Notes for the Coding Agent\n\n1. **Test independence:** Every test must be independently runnable. Use `RefreshDatabase` to ensure clean state. The login helper sequence (visit login, fill credentials, click sign in, assert dashboard) must be repeated per test that requires admin/customer auth.\n\n2. **No shared state between tests:** Do not rely on one test creating data that another test reads. Each test sets up its own preconditions.\n\n3. **Pest v4 syntax:** All tests use Pest's `it()` function, NOT class-based PHPUnit. No `$this->` within closures unless accessing Laravel test methods. Use `visit()`, `fill()`, `click()`, `assertSee()` as the primary browser interaction API.\n\n4. **Variant selection:** When adding products to cart, always select variant options first (e.g., click \"M\" for size, click \"Black\" for color) before clicking \"Add to cart\".\n\n5. **Payment testing:** All payments are processed in-process via the Mock PSP. For credit card tests, use the magic card number `4242 4242 4242 4242` for success, `4000 0000 0000 0002` for decline, and `4000 0000 0000 9995` for insufficient funds. No external API calls, webhooks, or redirects are involved.\n\n6. **Viewport testing (Suite 13):** The coding agent should use Pest v4 / Playwright's viewport configuration to set browser dimensions. The exact API for setting viewport in Pest v4 browser tests should be looked up via documentation search.\n\n7. **Form field names:** The field names used in `fill()` calls (e.g., \"email\", \"password\", \"first_name\", \"variants.0.price\") are logical names. The coding agent must use the actual HTML field names/labels as rendered by the Livewire components.\n\n8. **Product handles for inventory tests:** Product #17 and #18 handles are placeholders. The coding agent should use the actual handles from the seeder data or create products with known handles in the test setup.\n","structuredPatch":[{"oldStart":3137,"oldLines":7,"newStart":3137,"newLines":7,"lines":[" | 16 - Admin Customers | `tests/Browser/Admin/CustomerManagementTest.php` | 3 |"," | 17 - Admin Pages | `tests/Browser/Admin/PageManagementTest.php` | 3 |"," | 18 - Admin Analytics | `tests/Browser/Admin/AnalyticsTest.php` | 3 |","-| **Total** | **18 files** | **134** |","+| **Total** | **18 files** | **143** |"," "," ---"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"c3f436cf-df05-4e37-a0a9-a0925ce38eac"}
{"parentUuid":"fb87d6d5-b52c-4f53-8336-aeb5e9c8563a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01LmiQQZUPuS4eaFwpFBiGeN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JccKWWTFYkzs7mkn4pfn1W","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| Tests (Browser) | 18 files (134 tests) |","new_string":"| Tests (Browser) | 18 files (143 tests) |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":243,"cache_read_input_tokens":66795,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":243},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YQTRKNq2MWfxb8Xpq1","type":"assistant","uuid":"6ff0c4cd-0032-463a-9421-5fa8404363bd","timestamp":"2026-02-11T15:46:42.295Z"}
{"parentUuid":"6ff0c4cd-0032-463a-9421-5fa8404363bd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01JccKWWTFYkzs7mkn4pfn1W","toolUseID":"toolu_01JccKWWTFYkzs7mkn4pfn1W","timestamp":"2026-02-11T15:46:42.300Z","uuid":"40a00a8e-a799-4a36-aa20-594a89159987"}
{"parentUuid":"6ff0c4cd-0032-463a-9421-5fa8404363bd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JccKWWTFYkzs7mkn4pfn1W","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"e886f647-5e6c-499e-9e9f-e5a8da81bceb","timestamp":"2026-02-11T15:46:42.300Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| Tests (Browser) | 18 files (134 tests) |","newString":"| Tests (Browser) | 18 files (143 tests) |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |\n| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |\n| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |\n| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |\n| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |\n| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| selects a payment method | PUT /api/storefront/v1/checkouts/{id}/payment-method with method=credit_card | 200 response, status=payment_selected, payment_method stored | happy path |\n| completes checkout with credit card payment | POST /api/storefront/v1/checkouts/{id}/pay with card fields | 200 response, status=completed, order created | happy path |\n| rejects payment with declined card | POST /api/storefront/v1/checkouts/{id}/pay with magic decline number | 422 response, payment declined message | validation |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily, `CancelUnpaidBankTransferOrders` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (134 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":2014,"oldLines":7,"newStart":2014,"newLines":7,"lines":[" | Support classes | 2-3 |"," | Tests (Unit) | 6 |"," | Tests (Feature) | 28-30 |","-| Tests (Browser) | 18 files (134 tests) |","+| Tests (Browser) | 18 files (143 tests) |"," | CSS/JS | 3-5 |"," | **Total** | **~320-380 files** |"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"6ff0c4cd-0032-463a-9421-5fa8404363bd"}
{"parentUuid":"e886f647-5e6c-499e-9e9f-e5a8da81bceb","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01UsLPBwSzyiFYviExGAVHNf","type":"message","role":"assistant","content":[{"type":"text","text":"Finding #8 fixed. Now **Finding #9 (Low):** Fix webhook wording in spec 06 rate limiter table, and **Finding #10 (Low):** Fix PayPal copy in spec 04."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":444,"cache_read_input_tokens":67038,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":444},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YQqvQn24ztavuKsZNv","type":"assistant","uuid":"6d39c3f0-2219-4fd5-8fea-6a399dd9aeb4","timestamp":"2026-02-11T15:46:45.513Z"}
{"parentUuid":"6d39c3f0-2219-4fd5-8fea-6a399dd9aeb4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01UsLPBwSzyiFYviExGAVHNf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YWcRjFxFruQTDYqQERYeKj","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","old_string":"| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |","new_string":"| `webhooks` | 100 per minute | IP address | Outbound app webhook endpoints (reserved; no inbound payment webhooks) |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":444,"cache_read_input_tokens":67038,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":444},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YQqvQn24ztavuKsZNv","type":"assistant","uuid":"d84d0ae9-d8b6-4779-938c-f3b79135e499","timestamp":"2026-02-11T15:46:47.806Z"}
{"parentUuid":"d84d0ae9-d8b6-4779-938c-f3b79135e499","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01YWcRjFxFruQTDYqQERYeKj","toolUseID":"toolu_01YWcRjFxFruQTDYqQERYeKj","timestamp":"2026-02-11T15:46:47.810Z","uuid":"d3908bb0-5cd1-48c0-9ea5-e90c9033dac7"}
{"parentUuid":"d84d0ae9-d8b6-4779-938c-f3b79135e499","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YWcRjFxFruQTDYqQERYeKj","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md has been updated successfully."}]},"uuid":"2411cf08-ff3e-40eb-abd9-7459f4a8d19d","timestamp":"2026-02-11T15:46:47.810Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md","oldString":"| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |","newString":"| `webhooks` | 100 per minute | IP address | Outbound app webhook endpoints (reserved; no inbound payment webhooks) |","originalFile":"# 06 - Authentication and Security Specification\n\n> Shopify-clone e-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite\n\n---\n\n## 1. Authentication\n\nThe platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.\n\n### 1.1 Admin Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `web` (default Laravel guard) |\n| Provider name | `users` (Eloquent driver) |\n| Model | `App\\Models\\User` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /admin/login` |\n| Login form | Livewire component `Admin\\Auth\\Login` |\n\n#### Admin Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (web)\n    participant DB as Database\n    participant S as Session\n\n    B->>LW: Submit email + password\n    LW->>LW: Validate input (email required, password required)\n    LW->>LW: Check rate limiter (5/min per IP)\n    alt Rate limit exceeded\n        LW-->>B: Flash \"Too many attempts. Try again in X seconds.\"\n    end\n    LW->>Auth: attempt(credentials, remember)\n    Auth->>DB: SELECT from users WHERE email = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Credentials valid\n        Auth->>S: Regenerate session ID (prevent fixation)\n        Auth-->>LW: Success\n        LW-->>B: Redirect to /admin (dashboard)\n    else Credentials invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\" (generic, never reveal which field)\n    end\n```\n\n**Requirements:**\n\n- The login form collects `email` and `password`.\n- On successful login, the session MUST be regenerated to prevent session fixation attacks.\n- On failed login, only the generic message \"Invalid credentials\" is shown. The system must not indicate whether the email or password was wrong.\n- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.\n- After login: redirect to `/admin` (the dashboard).\n\n#### Remember Me\n\n- An optional \"Remember me\" checkbox on the login form.\n- When enabled, Laravel sets a long-lived remember token cookie.\n- The `remember_token` column on the `users` table stores the hashed token.\n- Cookie name follows Laravel default convention: `remember_web_{hash}`.\n\n#### Admin Password Reset Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant App as Application\n    participant DB as Database\n    participant Mail as Mail Service\n\n    B->>App: GET /admin/forgot-password\n    App-->>B: Render forgot-password form\n    B->>App: POST /admin/forgot-password (email)\n    App->>DB: Look up user by email\n    alt User exists\n        App->>DB: Store reset token in password_reset_tokens table\n        App->>Mail: Send reset link email\n    end\n    App-->>B: \"If that email exists, we sent a reset link.\" (generic)\n    B->>App: GET /admin/reset-password/{token}\n    App-->>B: Render reset-password form\n    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)\n    App->>DB: Validate token (exists, not expired, matches email)\n    alt Valid token\n        App->>DB: Update user password (bcrypt hash)\n        App->>DB: Delete used token\n        App-->>B: Redirect to /admin/login with success flash\n    else Invalid/expired token\n        App-->>B: Error message\n    end\n```\n\n**Requirements:**\n\n- Uses Laravel's built-in password broker (`users` broker).\n- Reset tokens stored in the `password_reset_tokens` table.\n- Token expiration: 60 minutes (configurable in `config/auth.php` under `passwords.users.expire`).\n- Throttle: one reset email per 60 seconds per email address.\n- The \"forgot password\" response must be generic (do not reveal whether email exists).\n\n#### Admin Logout\n\n- Route: `POST /admin/logout` (via Livewire action or standard route).\n- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.\n- The response must not cache.\n\n#### Session Lifetime\n\n| Setting | Value |\n|---------|-------|\n| Lifetime | 120 minutes (configurable via `SESSION_LIFETIME` env var) |\n| Expire on close | No (session persists until lifetime expires) |\n\n---\n\n### 1.2 Customer Authentication\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `customer` (custom guard) |\n| Provider name | `customers` (Eloquent driver) |\n| Model | `App\\Models\\Customer` |\n| Driver | Session (cookie-based) |\n| Login route | `GET /account/login` |\n| Registration route | `GET /account/register` |\n\n#### Customer Login Flow\n\n```mermaid\nsequenceDiagram\n    participant B as Browser\n    participant LW as Livewire Component\n    participant Auth as Auth Guard (customer)\n    participant Store as ResolveStore Middleware\n    participant DB as Database\n\n    Note over Store: Store already resolved from hostname\n    B->>LW: Submit email + password\n    LW->>LW: Validate input\n    LW->>LW: Check rate limiter (5/min per IP)\n    LW->>Auth: attempt(credentials + store_id, remember)\n    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?\n    Auth->>Auth: Verify bcrypt hash\n    alt Valid\n        Auth-->>LW: Success (regenerate session)\n        LW-->>B: Redirect to /account (or intended URL)\n    else Invalid\n        Auth-->>LW: Failure\n        LW-->>B: Flash \"Invalid credentials\"\n    end\n```\n\n**Requirements:**\n\n- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.\n- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.\n- The same email address can exist in different stores (multi-tenant isolation).\n- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.\n- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).\n\n#### Customer Registration Flow\n\n```mermaid\nflowchart TD\n    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]\n    B --> C{Validate via RegisterCustomerRequest}\n    C -- Invalid --> D[Show validation errors]\n    C -- Valid --> E[Create Customer record scoped to current store]\n    E --> F[Auto-login via customer guard]\n    F --> G[Redirect to /account]\n```\n\n**Registration fields:**\n\n| Field | Type | Required | Validation |\n|-------|------|:--------:|------------|\n| name | string | Y | max:255 |\n| email | string | Y | valid email, unique per store (composite unique on email + store_id) |\n| password | string | Y | min:8, confirmed |\n| password_confirmation | string | Y | must match password |\n| marketing_opt_in | boolean | N | defaults to false |\n\n#### Customer Store Scoping\n\n- The `customers` auth provider requires a custom `CustomerUserProvider` class.\n- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.\n- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).\n- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.\n\n#### Customer Password Reset\n\n- Uses a separate password broker: `customers` broker.\n- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).\n- Token records include `store_id` to scope resets to the correct store.\n- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.\n- Token expiration: 60 minutes.\n- Throttle: one reset email per 60 seconds.\n- Generic response on the \"forgot password\" form (do not reveal whether email exists in the store).\n\n---\n\n### 1.3 API Authentication (Sanctum)\n\n| Setting | Value |\n|---------|-------|\n| Guard name | `sanctum` |\n| Provider | `users` (admin users only) |\n| Token type | Personal access tokens |\n| Token prefix | `shop_` |\n| Hashing | SHA-256 |\n| Default expiration | 1 year |\n| Storage | `personal_access_tokens` table |\n\n#### Token Lifecycle\n\n```mermaid\nstateDiagram-v2\n    [*] --> Created: Admin creates token in Developers section\n    Created --> Active: Plain-text shown once, hash stored\n    Active --> InUse: Token sent as Bearer header\n    InUse --> Active: Request processed\n    Active --> Revoked: Admin revokes token\n    Active --> Expired: Expiration date reached\n    Revoked --> [*]: Token row deleted\n    Expired --> [*]: Token row deleted (cleanup job)\n```\n\n**Requirements:**\n\n- Tokens are created in the admin panel, under the Developers section.\n- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).\n- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.\n- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.\n- Sanctum middleware validates the hash and resolves the associated user.\n- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).\n- Revoking a token deletes its row from the `personal_access_tokens` table.\n\n#### Token Abilities (Scopes)\n\n| Ability | Description |\n|---------|-------------|\n| `read-products` | List and view products |\n| `write-products` | Create, update, delete products |\n| `read-orders` | List and view orders |\n| `write-orders` | Update orders, create fulfillments |\n| `read-customers` | List and view customers |\n| `write-customers` | Update customers |\n| `read-collections` | List and view collections |\n| `write-collections` | Create, update, delete collections |\n| `read-discounts` | List and view discounts |\n| `write-discounts` | Create, update, delete discounts |\n| `read-analytics` | View analytics data |\n| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |\n| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |\n| `read-themes` | View themes and theme files |\n| `write-themes` | Create, update, delete, publish themes |\n| `read-content` | View pages and navigation menus |\n| `write-content` | Create, update, delete pages and navigation items |\n| `manage-platform` | Platform-level management (super-admin only) |\n\nEach API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.\n\n---\n\n### 1.4 Guard Configuration\n\nFile to configure: `config/auth.php`\n\n#### Guards Data Dictionary\n\n| Guard Name | Driver | Provider | Purpose |\n|------------|--------|----------|---------|\n| `web` | session | `users` | Admin panel session auth (default guard) |\n| `customer` | session | `customers` | Storefront customer session auth |\n| `sanctum` | sanctum | `users` | Admin REST API token auth |\n\n#### Providers Data Dictionary\n\n| Provider Name | Driver | Model | Purpose |\n|---------------|--------|-------|---------|\n| `users` | eloquent | `App\\Models\\User` | Admin users |\n| `customers` | eloquent | `App\\Models\\Customer` | Storefront customers (store-scoped via custom provider) |\n\n#### Password Brokers Data Dictionary\n\n| Broker Name | Provider | Token Table | Expiry | Throttle |\n|-------------|----------|-------------|--------|----------|\n| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |\n| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |\n\n**Defaults:** The default guard is `web` and the default password broker is `users`.\n\n---\n\n## 2. Authorization\n\n### 2.1 Role Enum\n\nFile: `app/Enums/StoreUserRole.php`\n\nA backed string enum with four values:\n\n| Case | Stored Value | Description |\n|------|-------------|-------------|\n| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |\n| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |\n| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |\n| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |\n\n**Constraints:**\n\n- Each store must have exactly one Owner.\n- Ownership transfer is a dedicated action (not a simple role change).\n- A User can hold different roles in different stores (via the `store_users` pivot table).\n\n---\n\n### 2.2 Permission Matrix\n\nThe `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.\n\n| Resource | Action | Owner | Admin | Staff | Support |\n|----------|--------|:-----:|:-----:|:-----:|:-------:|\n| Store Settings | View | Y | Y | N | N |\n| Store Settings | Update | Y | Y | N | N |\n| Store | Delete | Y | N | N | N |\n| Products | List / View | Y | Y | Y | Y |\n| Products | Create | Y | Y | Y | N |\n| Products | Update | Y | Y | Y | N |\n| Products | Delete / Archive | Y | Y | N | N |\n| Products | Restore | Y | Y | N | N |\n| Collections | List / View | Y | Y | Y | Y |\n| Collections | Create | Y | Y | Y | N |\n| Collections | Update | Y | Y | Y | N |\n| Collections | Delete | Y | Y | N | N |\n| Orders | List / View | Y | Y | Y | Y |\n| Orders | Update | Y | Y | Y | N |\n| Orders | Cancel | Y | Y | N | N |\n| Fulfillments | Create | Y | Y | Y | N |\n| Fulfillments | Update / Cancel | Y | Y | Y | N |\n| Refunds | Create | Y | Y | N | N |\n| Customers | List / View | Y | Y | Y | Y |\n| Customers | Update | Y | Y | Y | N |\n| Discounts | List / View | Y | Y | Y | Y |\n| Discounts | Create | Y | Y | Y | N |\n| Discounts | Update | Y | Y | Y | N |\n| Discounts | Delete | Y | Y | N | N |\n| Themes | List / View | Y | Y | N | N |\n| Themes | Create / Upload | Y | Y | N | N |\n| Themes | Customize | Y | Y | N | N |\n| Themes | Publish | Y | Y | N | N |\n| Themes | Delete | Y | Y | N | N |\n| Pages | List / View | Y | Y | Y | N |\n| Pages | Create | Y | Y | Y | N |\n| Pages | Update | Y | Y | Y | N |\n| Pages | Delete | Y | Y | N | N |\n| Navigation | List / View | Y | Y | Y | N |\n| Navigation | Manage | Y | Y | N | N |\n| Apps | List / View | Y | Y | N | N |\n| Apps | Install / Remove | Y | Y | N | N |\n| Analytics | View | Y | Y | Y | N |\n| Developers / API | Manage | Y | Y | N | N |\n| Staff | List / View | Y | Y | N | N |\n| Staff | Invite | Y | Y | N | N |\n| Staff | Update Role | Y | Y | N | N |\n| Staff | Remove | Y | Y | N | N |\n| Shipping Zones | Manage | Y | Y | N | N |\n| Tax Settings | Manage | Y | Y | N | N |\n| Search Settings | Manage | Y | Y | N | N |\n\n#### Role Group Shorthand\n\nThe permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:\n\n| Group Name | Roles Included | Used For |\n|------------|---------------|----------|\n| OwnerOnly | Owner | Store deletion, ownership transfer |\n| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |\n| OwnerAdminOrStaff | Owner, Admin, Staff | Create/update products, collections, discounts, pages, fulfillments, order updates |\n| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |\n\n---\n\n### 2.3 Role Checking Trait\n\nFile: `app/Traits/ChecksStoreRole.php`\n\nA reusable trait included in all policies and used by gate definitions. Must provide the following methods:\n\n| Method | Parameters | Returns | Description |\n|--------|-----------|---------|-------------|\n| `getStoreRole` | User, store_id (int) | StoreUserRole or null | Looks up the user's role for the given store via the `store_users` relationship. Returns null if the user has no role in that store. |\n| `hasRole` | User, store_id (int), roles (array of StoreUserRole) | bool | Returns true if the user's role for the store is in the provided list. Returns false if user has no role. |\n| `isOwnerOrAdmin` | User, store_id (int) | bool | Shorthand: checks if role is Owner or Admin. |\n| `isOwnerAdminOrStaff` | User, store_id (int) | bool | Shorthand: checks if role is Owner, Admin, or Staff. |\n| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |\n\n**Store ID resolution:** When the policy method receives a model instance (e.g., Product), the store_id comes from the model's `store_id` attribute. When the policy method does not receive a model (e.g., `viewAny`, `create`), the store_id is resolved from the container-bound `current_store`.\n\n---\n\n### 2.4 Laravel Policies\n\nAll policies use the `ChecksStoreRole` trait. Laravel 12 auto-discovers policies by convention (`App\\Policies\\{Model}Policy`), so no manual registration is needed.\n\nEvery policy method follows this pattern:\n1. Determine the `store_id` (from the model if available, otherwise from the resolved current store).\n2. Use the appropriate role-checking helper method.\n3. Return a boolean.\n\n#### ProductPolicy\n\nFile: `app/Policies/ProductPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Product)` | Yes | AnyRole | product.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Product)` | Yes | OwnerAdminOrStaff | product.store_id |\n| `delete(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `archive(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n| `restore(User, Product)` | Yes | OwnerOrAdmin | product.store_id |\n\n#### OrderPolicy\n\nFile: `app/Policies/OrderPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Order)` | Yes | AnyRole | order.store_id |\n| `update(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `cancel(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n| `createFulfillment(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |\n| `createRefund(User, Order)` | Yes | OwnerOrAdmin | order.store_id |\n\n#### CollectionPolicy\n\nFile: `app/Policies/CollectionPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Collection)` | Yes | AnyRole | collection.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Collection)` | Yes | OwnerAdminOrStaff | collection.store_id |\n| `delete(User, Collection)` | Yes | OwnerOrAdmin | collection.store_id |\n\n#### DiscountPolicy\n\nFile: `app/Policies/DiscountPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Discount)` | Yes | AnyRole | discount.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Discount)` | Yes | OwnerAdminOrStaff | discount.store_id |\n| `delete(User, Discount)` | Yes | OwnerOrAdmin | discount.store_id |\n\n#### CustomerPolicy\n\nFile: `app/Policies/CustomerPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | AnyRole | current_store |\n| `view(User, Customer)` | Yes | AnyRole | customer.store_id |\n| `update(User, Customer)` | Yes | OwnerAdminOrStaff | customer.store_id |\n\n#### ThemePolicy\n\nFile: `app/Policies/ThemePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerOrAdmin | current_store |\n| `view(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `create(User)` | No | OwnerOrAdmin | current_store |\n| `update(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `delete(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n| `publish(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |\n\n#### PagePolicy\n\nFile: `app/Policies/PagePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `view(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `create(User)` | No | OwnerAdminOrStaff | current_store |\n| `update(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |\n| `delete(User, Page)` | Yes | OwnerOrAdmin | page.store_id |\n\n#### StorePolicy\n\nFile: `app/Policies/StorePolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `updateSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |\n| `delete(User, Store)` | Yes | OwnerOnly | store.id |\n\n#### FulfillmentPolicy\n\nFile: `app/Policies/FulfillmentPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerAdminOrStaff | order.store_id |\n| `update(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n| `cancel(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |\n\nNote: `create` receives the parent Order, not a Fulfillment, since the Fulfillment does not exist yet. The `update` and `cancel` methods navigate through the Fulfillment's order relationship to get the store_id.\n\n#### RefundPolicy\n\nFile: `app/Policies/RefundPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `create(User, Order)` | Yes (Order) | OwnerOrAdmin | order.store_id |\n\nNote: `create` receives the parent Order for the same reason as FulfillmentPolicy.\n\n#### NavigationMenuPolicy\n\nFile: `app/Policies/NavigationMenuPolicy.php`\n\n| Method | Receives Model? | Required Roles | Store ID Source |\n|--------|:--------------:|----------------|-----------------|\n| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |\n| `manage(User)` | No | OwnerOrAdmin | current_store |\n\n---\n\n### 2.5 Gates (Non-Model Operations)\n\nGates are registered in `AppServiceProvider` (or a dedicated `AuthServiceProvider`). Each gate resolves the current store from the container and checks the user's role via the `store_users` relationship.\n\n| Gate Name | Required Roles | Purpose |\n|-----------|---------------|---------|\n| `manage-store-settings` | Owner, Admin | Access store settings pages |\n| `manage-staff` | Owner, Admin | Invite, update, remove staff members |\n| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |\n| `view-analytics` | Owner, Admin, Staff | View analytics dashboard and reports |\n| `manage-shipping` | Owner, Admin | Manage shipping zones, rates, profiles |\n| `manage-taxes` | Owner, Admin | Manage tax settings and rates |\n| `manage-search-settings` | Owner, Admin | Configure search weights and synonyms |\n| `manage-navigation` | Owner, Admin | Create/edit/delete navigation menus |\n| `manage-apps` | Owner, Admin | Install and remove third-party apps |\n\n**Gate resolution pattern (all gates follow this):**\n\n1. Resolve the current store from the container\n2. Look up the user's `store_users` record for that store\n3. If no record exists, return false\n4. If the user's role is in the gate's required roles list, return true\n5. Otherwise return false\n\n---\n\n### 2.6 Policy and Gate Usage Patterns\n\n#### In Livewire Components\n\n- List views: call `authorize('viewAny', ModelClass)` in the `mount()` lifecycle method.\n- Action methods (delete, update, etc.): call `authorize('action', $modelInstance)` at the start of the action, before performing any mutation.\n- If authorization fails, Laravel throws `AuthorizationException`, which renders as 403.\n\n#### In API Controllers\n\n- Use `$this->authorize('action', $model)` at the start of each controller method.\n- Alternatively, use `can` middleware on routes for simpler checks.\n- Form Requests also implement `authorize()` to check the relevant policy or gate before validation runs.\n\n#### In Blade Templates\n\n- Use `@can('gate-name')` / `@cannot` directives to conditionally show/hide navigation items and action buttons.\n- This is purely cosmetic (UI hiding) - the actual authorization check happens server-side in policies/gates.\n\n---\n\n## 3. Middleware Stack\n\n### 3.1 Middleware Registration\n\nFile: `bootstrap/app.php`\n\nThree custom middleware aliases must be registered:\n\n| Alias | Class | Purpose |\n|-------|-------|---------|\n| `store.resolve` | `App\\Http\\Middleware\\ResolveStore` | Resolves current store from hostname or session |\n| `role.check` | `App\\Http\\Middleware\\CheckStoreRole` | Checks user has required role for current store |\n| `auth:customer` | `App\\Http\\Middleware\\CustomerAuthenticate` | Ensures customer is authenticated via customer guard |\n\n---\n\n### 3.2 Route Middleware Groups\n\n| Route Group | Middleware Stack | Description |\n|-------------|-----------------|-------------|\n| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |\n| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |\n| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |\n| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |\n| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |\n| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |\n| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |\n\n---\n\n### 3.3 Custom Middleware Specifications\n\n#### ResolveStore\n\nFile: `app/Http/Middleware/ResolveStore.php`\n\n**Purpose:** Resolves the current store from the request hostname and binds it to the service container.\n\n```mermaid\nflowchart TD\n    A[Extract hostname from request] --> B{Route type?}\n    B -- Storefront --> C{Match hostname in store_domains table?}\n    C -- Yes --> F[Store found]\n    C -- No --> E[Abort 404: Store not found]\n    F --> G{Store status == suspended?}\n    G -- Yes --> H[Abort 503: Store unavailable]\n    G -- No --> I[Bind Store to container as 'current_store']\n    I --> J[Share Store with all views as 'currentStore']\n    J --> K[Continue to next middleware]\n    B -- Admin --> L[Read store_id from session 'current_store_id']\n    L --> M{User has store_users record?}\n    M -- No --> N[Abort 403]\n    M -- Yes --> I\n```\n\n**Requirements:**\n\nStorefront requests: Resolve store from the `store_domains` table by matching the request hostname against `store_domains.hostname`. Cache the hostname-to-store mapping using file cache (tag: `store-domains`).\n\nAdmin requests: Store is determined by session key `current_store_id`, set after login or via the store-switcher in the admin panel. Admin routes do NOT use hostname resolution. The `admin.acme-fashion.test` domain in seeders routes requests to the admin panel, but the actual store context within the admin is determined by the session `current_store_id` after login.\n\n**Shared steps (after store is resolved):**\n\n1. If the store's `status` column is `'suspended'`: abort with HTTP 503 and message \"This store is currently unavailable.\" (For admin routes: abort 403 for mutations instead.)\n2. Bind the resolved Store model instance to the service container with the key `'current_store'`.\n3. Share the store with all Blade views under the variable name `currentStore`.\n4. Allow the request to proceed.\n\n#### CheckStoreRole\n\nFile: `app/Http/Middleware/CheckStoreRole.php`\n\n**Purpose:** Verifies the authenticated user has one of the specified roles for the current store.\n\n**Route usage:** Applied as `role.check:owner,admin` or `role.check:owner,admin,staff` (comma-separated role values passed as variadic parameters).\n\n```mermaid\nflowchart TD\n    A[Resolve current_store from container] --> B[Get authenticated user]\n    B --> C[Look up store_users record for user + store]\n    C --> D{Record exists?}\n    D -- No --> E[Abort 403: No access to this store]\n    D -- Yes --> F[Parse allowed roles from middleware parameters]\n    F --> G{User's role in allowed list?}\n    G -- No --> H[Abort 403: Insufficient permissions]\n    G -- Yes --> I[Attach store_user to request attributes]\n    I --> J[Continue to next middleware]\n```\n\n**Requirements:**\n\n1. Resolve the current store from the container.\n2. Get the authenticated user from the request.\n3. Query the user's `store_users` relationship filtered by `store_id`.\n4. If no record exists: abort 403 with message \"You do not have access to this store.\"\n5. Convert the middleware parameter strings to `StoreUserRole` enum instances.\n6. If the user's role is not in the allowed list: abort 403 with message \"Insufficient permissions.\"\n7. Store the resolved `StoreUser` pivot record on the request attributes (key: `store_user`) for downstream use.\n8. Allow the request to proceed.\n\n#### CustomerAuthenticate\n\nFile: `app/Http/Middleware/CustomerAuthenticate.php`\n\n**Purpose:** Ensures the request is authenticated via the `customer` guard. Redirects unauthenticated customers to login.\n\n```mermaid\nflowchart TD\n    A[Check customer guard authentication] --> B{Authenticated?}\n    B -- Yes --> C[Continue to next middleware]\n    B -- No --> D[Store current URL as intended redirect]\n    D --> E[Redirect to /account/login]\n```\n\n**Requirements:**\n\n1. Check if `Auth::guard('customer')` reports the user as authenticated.\n2. If authenticated: allow the request to proceed.\n3. If not authenticated: save the current URL in the session as the intended URL (so the customer returns there after login), then redirect to the storefront login route (`/account/login`).\n\n---\n\n## 4. Security Controls\n\n### 4.1 CSRF Protection\n\n| Context | Mechanism |\n|---------|-----------|\n| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |\n| Livewire actions | Livewire handles CSRF verification automatically on every request |\n| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |\n| SPA clients | `GET /sanctum/csrf-cookie` sets XSRF-TOKEN cookie for session-based API calls |\n\n**Requirements:**\n\n- CSRF verification is enforced globally on all `web` middleware group routes.\n- No routes within the `web` group may be excluded from CSRF verification.\n- API routes (under `api` middleware group) are inherently exempt since they use token auth.\n\n---\n\n### 4.2 Rate Limiting\n\nAll rate limiters are defined in `bootstrap/app.php` (or `AppServiceProvider::boot()`).\n\n| Limiter Name | Limit | Keyed By | Applied To |\n|-------------|-------|----------|------------|\n| `login` | 5 per minute | IP address | Admin login, customer login |\n| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |\n| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |\n| `checkout` | 10 per minute | Session ID | Checkout-related operations |\n| `search` | 30 per minute | IP address | Search queries |\n| `analytics` | 60 per minute | IP address | Analytics event ingestion |\n| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |\n\n**Rate limit response headers** (returned on all API responses by Laravel default):\n\n| Header | Description |\n|--------|-------------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the window |\n| `X-RateLimit-Remaining` | Requests remaining in the current window |\n| `Retry-After` | Seconds until the rate limit resets (only on 429 responses) |\n\nWhen a rate limit is exceeded, the response is HTTP 429 Too Many Requests.\n\n---\n\n### 4.3 Encryption at Rest\n\nEncryption algorithm: AES-256-CBC with MAC verification, keyed by the application's `APP_KEY`.\n\n**Fields requiring encrypted storage (using Laravel's `encrypted` cast):**\n\n| Model | Column | Cast Type | Purpose |\n|-------|--------|-----------|---------|\n| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |\n| WebhookSubscription | `signing_secret_encrypted` | `encrypted` | Per-subscription signing secrets for outbound webhooks |\n| Payment | `raw_json` | `encrypted:array` | Full payment gateway response (may contain sensitive card/account data) |\n\n**Requirements:**\n\n- These columns are declared with the `encrypted` (or `encrypted:array`) cast in the model's `casts()` method.\n- Data is automatically encrypted before writing to the database and decrypted when reading.\n- The encrypted columns store opaque ciphertext (type TEXT in SQLite). They cannot be queried with WHERE clauses at the database level.\n\n---\n\n### 4.4 Input Validation\n\nAll write endpoints must use dedicated Form Request classes. Validation rules use string syntax per project convention.\n\n#### Form Request Inventory\n\n| Form Request Class | Used By | Key Validation Rules |\n|-------------------|---------|---------------------|\n| `StoreProductRequest` | Create product | `title`: required, string, max:255. `description_html`: nullable, string. `status`: required, in:active,draft,archived. `vendor`: nullable, string. Price fields: required, integer, min:0. |\n| `UpdateProductRequest` | Update product | Same fields as StoreProductRequest but all optional (sometimes rule). |\n| `StoreCollectionRequest` | Create collection | `title`: required, string, max:255. `type`: required, in:manual,automated. `rules_json`: required_if:type,automated, valid JSON. |\n| `UpdateCollectionRequest` | Update collection | Same as StoreCollectionRequest, all optional. |\n| `StoreDiscountRequest` | Create discount | `type`: required, in:code,automatic. `code`: required_if:type,code, string, max:50, unique per store (case-insensitive). `value_type`: required, in:fixed,percent,free_shipping. `value_amount`: required_unless:value_type,free_shipping, integer, min:0; when value_type is `percent`, max:100. `minimum_order_amount`: nullable, integer, min:0. `usage_limit`: nullable, integer, min:1. `starts_at`: nullable, date, ISO-8601. `ends_at`: nullable, date, after:starts_at. |\n| `UpdateDiscountRequest` | Update discount | Same as StoreDiscountRequest, code unique ignoring self. |\n| `SetCheckoutAddressRequest` | Set shipping/billing address | `first_name`, `last_name`, `address1`, `city`, `country_code`: required. `province_code`: required when applicable. `postal_code`: required. `phone`: nullable. |\n| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |\n| `CreateFulfillmentRequest` | Create fulfillment | `line_item_ids`: required, array of existing line item IDs. `tracking_company`: nullable, string. `tracking_number`: nullable, string. `tracking_url`: nullable, valid URL. |\n| `CreateRefundRequest` | Create refund | `line_items`: required, array with `id` and `quantity` per item. `reason`: required, string. `amount`: required, integer, min:0, max:original payment amount. |\n| `UpdateStoreSettingsRequest` | Update store settings | `name`: required, string. `default_currency`: in allowed currencies. `default_locale`: in allowed locales. `timezone`: valid timezone string. |\n| `InviteStaffRequest` | Invite staff member | `email`: required, valid email. `role`: required, in:admin,staff,support (not owner). |\n| `RegisterCustomerRequest` | Customer registration | `name`: required. `email`: required, email, unique per store (composite). `password`: required, min:8, confirmed. |\n| `LoginRequest` | Admin/customer login | `email`: required, email. `password`: required, string. |\n\n**Authorization in Form Requests:**\n\nEach Form Request's `authorize()` method must check the relevant policy or gate. For example, `StoreProductRequest::authorize()` checks whether the user can create a Product. If authorization fails, the request is rejected with 403 before validation even runs.\n\n---\n\n### 4.5 XSS Prevention\n\n| Context | Strategy |\n|---------|----------|\n| Standard Blade output | Double-curly-brace syntax auto-escapes all output (Laravel default) |\n| Rich HTML output | Unescaped syntax used only for trusted HTML fields: product `description_html`, page `body_html` |\n| HTML input sanitization | Use HTMLPurifier (or equivalent) on save, before persisting to database |\n| Livewire properties | Auto-escaped by default when rendered in Blade |\n| JSON API responses | Laravel escapes JSON output by default |\n\n#### HTML Sanitization Requirements\n\nAn `SanitizeHtml` action class must be created at `app/Actions/SanitizeHtml.php`.\n\n**Allowed HTML elements and attributes:**\n\n| Element | Allowed Attributes |\n|---------|-------------------|\n| `p`, `br`, `strong`, `em`, `u` | none |\n| `ol`, `ul`, `li` | none |\n| `a` | `href` |\n| `img` | `src`, `alt` |\n| `h1` through `h6` | none |\n| `blockquote` | none |\n| `table`, `thead`, `tbody`, `tr`, `th`, `td` | none |\n| `div`, `span` | none |\n\nAll other elements and attributes must be stripped. Empty elements should be removed.\n\n**Where sanitization is applied:** Before persisting product `description_html` and page `body_html` fields. The sanitization runs during the create and update operations for these models.\n\n---\n\n### 4.6 SQL Injection Prevention\n\n**Requirements:**\n\n- All database queries must use Eloquent ORM with parameterized bindings (Laravel default behavior).\n- No raw query strings may contain unsanitized user input.\n- Any use of `whereRaw`, `selectRaw`, or `DB::raw` must use parameter bindings (the `?` placeholder syntax).\n- FTS5 (full-text search) queries require special handling: user input must be sanitized before building the MATCH expression.\n\n#### FTS5 Input Sanitization\n\nBefore using user input in an FTS5 MATCH expression:\n\n1. Strip all characters that are not Unicode letters, Unicode numbers, or whitespace (remove FTS5 special operators like `*`, `AND`, `OR`, `NOT`, `NEAR`, quotes, parentheses, etc.).\n2. Trim leading and trailing whitespace.\n3. Pass the sanitized string via a parameterized binding (never concatenate into the query string).\n\n---\n\n### 4.7 Mass Assignment Protection\n\n**Requirements:**\n\n- Every Eloquent model must define a `$fillable` array listing only the columns that are safe for mass assignment.\n- The pattern `$guarded = []` is never permitted in any model.\n- All controller and Livewire action code must use `$request->validated()` (not `$request->all()` or `$request->input()`) when passing data to model creation or update methods.\n\n---\n\n### 4.8 Password Security\n\n| Setting | Value |\n|---------|-------|\n| Minimum length | 8 characters (for both admin and customer passwords) |\n| Hashing algorithm | Bcrypt (Laravel default) |\n| Password in logs | Never logged - use Laravel's built-in log redaction |\n| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |\n\n**Model requirements:**\n\n- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.\n- Password validation rules must enforce minimum 8 characters and use the `confirmed` rule (requiring a matching `password_confirmation` field).\n\n**Important:** Both the `User` and `Customer` models use `password_hash` as the database column name instead of Laravel's default `password`. Both models must override `getAuthPassword()`:\n\n```php\npublic function getAuthPassword(): string\n{\n    return $this->password_hash;\n}\n```\n\nThis is required for `Auth::attempt()` and `Hash::check()` to work correctly with the custom column name.\n\n---\n\n## 5. Session Configuration\n\nFile: `config/session.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `driver` | `'file'` | Simple, no external dependencies. Compatible with SQLite-based stack without needing Redis. |\n| `lifetime` | 120 (minutes) | Configurable via `SESSION_LIFETIME` env var. |\n| `expire_on_close` | `false` | Session persists until lifetime expires, not on browser close. |\n| `encrypt` | `true` | Session data encrypted at rest on disk. |\n| `cookie` | `'shop_session'` | Custom name to avoid generic fingerprinting. Configurable via `SESSION_COOKIE` env var. |\n| `path` | `'/'` | Cookie available across entire domain. |\n| `domain` | `null` | Laravel determines from request. |\n| `secure` | `true` | HTTPS-only cookies in production. Configurable via `SESSION_SECURE_COOKIE` env var (set to false for local dev). |\n| `http_only` | `true` | Cookie inaccessible to JavaScript (mitigates XSS-based cookie theft). |\n| `same_site` | `'lax'` | Allows navigation-initiated requests while preventing CSRF from external origins. |\n| `partitioned` | `false` | Not needed for this use case. |\n\n---\n\n## 6. CORS Configuration\n\nFile: `config/cors.php`\n\n| Setting | Value | Rationale |\n|---------|-------|-----------|\n| `paths` | `['api/*', 'sanctum/csrf-cookie']` | Only API and Sanctum CSRF cookie endpoints are CORS-enabled. Web routes are not. |\n| `allowed_methods` | `['*']` | All HTTP methods allowed for API. |\n| `allowed_origins` | `[FRONTEND_URL env var, default '*']` | Restrict to store domain in production. Wildcard for development. |\n| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |\n| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |\n| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |\n| `max_age` | `0` | No preflight caching (conservative default). |\n| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |\n\n---\n\n## 7. Webhook Signature Verification\n\n### 7.1 Inbound Payment Webhooks\n\n> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).\n\n---\n\n### 7.2 Platform Webhooks (Outbound)\n\nService: `App\\Services\\WebhookDispatcher`\n\nWhen the platform sends webhooks to external subscribers (third-party apps, integrations).\n\n```mermaid\nsequenceDiagram\n    participant App as Platform\n    participant Sub as Subscriber URL\n\n    App->>App: Serialize payload as JSON\n    App->>App: Generate Unix timestamp\n    App->>App: Concatenate: \"{timestamp}.{json_body}\"\n    App->>App: HMAC-SHA256(signing_secret, concatenated_message)\n    App->>Sub: POST to subscriber URL\n    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json\n    Sub-->>App: 200 OK (acknowledged)\n```\n\n**Requirements:**\n\n1. The payload is serialized as JSON.\n2. A Unix timestamp is generated (current time).\n3. The message to sign is: `\"{timestamp}.{json_body}\"` (timestamp, dot, JSON string).\n4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).\n5. The HTTP request to the subscriber includes three headers:\n   - `X-Platform-Signature`: the hex-encoded HMAC digest\n   - `X-Platform-Timestamp`: the Unix timestamp as a string\n   - `Content-Type`: `application/json`\n6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.\n\n---\n\n## 8. Audit Logging\n\n### 8.1 Logging Channel\n\nA dedicated `audit` channel must be added to `config/logging.php`.\n\n| Setting | Value |\n|---------|-------|\n| Driver | `daily` (file-based, daily rotation) |\n| Path | `storage/logs/audit.log` |\n| Level | `info` |\n| Retention | 90 days |\n\n---\n\n### 8.2 Log Entry Format\n\nAll audit log entries are structured JSON objects with the following schema:\n\n| Field | Type | Required | Description |\n|-------|------|:--------:|-------------|\n| `timestamp` | string (ISO-8601) | Y | When the event occurred |\n| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |\n| `user_id` | integer or null | N | ID of the admin user who triggered the event |\n| `store_id` | integer or null | N | ID of the store context |\n| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |\n| `resource_id` | integer or null | N | ID of the affected resource |\n| `ip` | string | Y | Client IP address |\n| `user_agent` | string | Y | Client user agent string |\n| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |\n| (additional keys) | varies | N | Event-specific extra data |\n\n---\n\n### 8.3 Events to Log\n\n#### Authentication Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `auth.login` | Successful admin login | user_id, ip, user_agent |\n| `auth.logout` | Admin logout | user_id |\n| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |\n| `customer.login` | Successful customer login | customer_id, store_id, ip |\n| `customer.registered` | New customer registration | customer_id, store_id |\n| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |\n\n#### Admin Resource Events\n\nFor each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:\n\n**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff\n\n| Event Pattern | Trigger | Extra Data |\n|---------------|---------|------------|\n| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |\n| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |\n| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |\n| `{resource}.restored` | Resource restored from soft-delete | resource_id |\n\n#### Security Events\n\n| Event ID | Trigger | Extra Data |\n|----------|---------|------------|\n| `api_token.created` | New API token issued | token_name, abilities list, user_id |\n| `api_token.revoked` | API token revoked | token_name, user_id |\n| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |\n| `store.settings_changed` | Store settings updated | changed fields with old/new values |\n\n---\n\n### 8.4 Audit Logger Service\n\nFile: `app/Services/AuditLogger.php`\n\nA service class that encapsulates audit log writing.\n\n**Method signature:**\n\n| Parameter | Type | Required | Description |\n|-----------|------|:--------:|-------------|\n| `event` | string | Y | Event identifier |\n| `userId` | int or null | N | Acting user ID |\n| `storeId` | int or null | N | Store context ID |\n| `resourceType` | string or null | N | Affected resource type |\n| `resourceId` | int or null | N | Affected resource ID |\n| `extra` | associative array | N | Additional key-value pairs to include in the log entry |\n\n**Behavior:**\n\n1. Write to the `audit` logging channel at the `info` level.\n2. Automatically include `timestamp` (ISO-8601), `ip` (from request), and `user_agent` (from request) in every entry.\n3. Merge any `extra` data into the log context.\n\n---\n\n### 8.5 Integration with Laravel Events\n\nThe audit logger hooks into Laravel's built-in authentication events and custom model observers.\n\n#### Auth Event Listeners\n\nRegister listeners (in `AppServiceProvider` or `EventServiceProvider`) for these Laravel events:\n\n| Laravel Event | Audit Event ID | Data Captured |\n|--------------|----------------|---------------|\n| `Illuminate\\Auth\\Events\\Login` | `auth.login` | user_id from event |\n| `Illuminate\\Auth\\Events\\Failed` | `auth.failed_login` | email from credentials (never password) |\n| `Illuminate\\Auth\\Events\\Logout` | `auth.logout` | user_id from event |\n\n#### Model Observers\n\nFor each auditable resource model, create an observer (or use a shared trait) that logs `created`, `updated`, and `deleted` events. The `updated` observer should capture the changed fields (dirty attributes) with their original and new values.\n\n---\n\n## 9. Security Checklist Summary\n\n| # | Control | Implementation Approach | Priority |\n|---|---------|------------------------|----------|\n| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |\n| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |\n| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |\n| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |\n| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |\n| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |\n| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |\n| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |\n| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |\n| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |\n| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |\n| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |\n| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |\n| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |\n| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |\n| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |\n","structuredPatch":[{"oldStart":741,"oldLines":7,"newStart":741,"newLines":7,"lines":[" | `checkout` | 10 per minute | Session ID | Checkout-related operations |"," | `search` | 30 per minute | IP address | Search queries |"," | `analytics` | 60 per minute | IP address | Analytics event ingestion |","-| `webhooks` | 100 per minute | IP address | Incoming webhook endpoints |","+| `webhooks` | 100 per minute | IP address | Outbound app webhook endpoints (reserved; no inbound payment webhooks) |"," "," **Rate limit response headers** (returned on all API responses by Laravel default):"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"d84d0ae9-d8b6-4779-938c-f3b79135e499"}
{"parentUuid":"2411cf08-ff3e-40eb-abd9-7459f4a8d19d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01UsLPBwSzyiFYviExGAVHNf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VspPkNPaSpvCP89aGXDjAj","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","old_string":"+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+","new_string":"+--------------------------------------------------------------+\n| Your PayPal payment will be processed securely.               |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":444,"cache_read_input_tokens":67038,"output_tokens":468,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":444,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2YQqvQn24ztavuKsZNv","type":"assistant","uuid":"a4722e7b-0804-4735-bcf4-e5bae6ae734b","timestamp":"2026-02-11T15:46:50.979Z"}
{"parentUuid":"a4722e7b-0804-4735-bcf4-e5bae6ae734b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01VspPkNPaSpvCP89aGXDjAj","toolUseID":"toolu_01VspPkNPaSpvCP89aGXDjAj","timestamp":"2026-02-11T15:46:50.988Z","uuid":"bc645250-2f93-43c4-acb9-5a98b9a5f974"}
{"parentUuid":"a4722e7b-0804-4735-bcf4-e5bae6ae734b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VspPkNPaSpvCP89aGXDjAj","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md has been updated successfully."}]},"uuid":"2bd87948-8a88-4ebd-8694-603b4a960435","timestamp":"2026-02-11T15:46:50.987Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/04-STOREFRONT-UI.md","oldString":"+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+","newString":"+--------------------------------------------------------------+\n| Your PayPal payment will be processed securely.               |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+","originalFile":"# 04 - Storefront UI/UX Specification\n\n> Shopify-clone e-commerce platform - Customer-facing storefront UI/UX requirements\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |\n| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |\n| Templating | Blade templates with reusable storefront-prefixed components |\n| Accessibility | WCAG 2.2 AA compliance throughout |\n| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |\n| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |\n| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |\n\n---\n\n## Currency Formatting\n\nAll monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:\n\n| Setting | Value |\n|---------|-------|\n| Decimal separator | `.` (period) |\n| Thousands separator | `,` (comma) |\n| Currency symbol | After amount with space (e.g., `24.99 EUR`) |\n| Decimal places | Always 2 |\n| Free amounts | Display as `0.00 EUR` |\n| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |\n\n**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount=\"$amount\" :currency=\"$currency\" />`) to ensure consistent formatting across all storefront pages.\n\n**Examples:**\n- 2499 cents -> `24.99 EUR`\n- 0 cents -> `0.00 EUR`\n- 149900 cents -> `1,499.00 EUR`\n\n---\n\n## 1. Theme System Architecture\n\n### 1.1 View Organization\n\nThe storefront views are organized into the following categories:\n\n| Category | Purpose | Views |\n|---|---|---|\n| Layouts | Master page shell | app (master layout) |\n| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |\n| Checkout | Purchase flow | checkout index, order confirmation |\n| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |\n| Errors | Error states | 404, 503 |\n| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |\n\n### 1.2 Layout Hierarchy\n\nAll storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.\n\n### 1.3 Theme Settings\n\nTheme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:\n\n- Announcement bar visibility, text, link, and background color\n- Sticky header toggle\n- Home page section order and visibility\n- Color palette overrides (primary, secondary, accent)\n- Typography scale\n- Footer content and social links\n- Dark mode behavior (system preference, toggle, or forced)\n\n### 1.4 Section-Based Architecture\n\nEach page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.\n\n| Section | Description | Used On |\n|---|---|---|\n| Hero | Full-width banner with heading, subheading, call-to-action | Home |\n| Featured Collections | Grid of collection cards | Home |\n| Featured Products | Grid of product cards (lazy-loaded) | Home |\n| Newsletter | Email signup form | Home, Footer |\n| Rich Text | Customizable content block | Home, Pages |\n\n### 1.5 Blade Components\n\nAll reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.\n\n---\n\n## 2. Base Layout\n\n### 2.1 Document Head\n\nThe document head includes:\n\n| Element | Description |\n|---|---|\n| Character encoding | UTF-8 |\n| Viewport | Width = device width, initial scale 1 |\n| Meta description | Page-specific, yielded from child templates |\n| Title | Page-specific title appended with the app name |\n| Stylesheets | Compiled CSS via Vite |\n| Scripts | Compiled JS via Vite |\n| Livewire styles | Injected by Livewire |\n| Language attribute | Set on the HTML element from application locale |\n| Scroll behavior | Smooth scrolling enabled globally |\n\n### 2.2 Skip Link\n\nA visually hidden \"Skip to main content\" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.\n\n**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.\n\n### 2.3 Announcement Bar\n\nOptional, controlled by theme settings. Renders a full-width bar above the header.\n\n| Attribute | Description |\n|---|---|\n| Position | Full-width, above the header |\n| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |\n| Text | Centered, small size, configurable content |\n| Link | Optional, underlined, opens in same tab |\n| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |\n| Height | Auto height with vertical padding |\n\n### 2.4 Header\n\nThe header serves as the primary navigation banner.\n\n**Desktop Layout (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |\n| Main Navigation | Center | Horizontal nav with links sourced from the \"main-menu\" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |\n| Search Icon | Right group | Button that opens the search modal |\n| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |\n| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |\n\n**Mobile Layout (below large breakpoint):**\n\n```\n+--------------------------------------------------------------+\n| [Hamburger]           [Logo]                            [Cart]|\n+--------------------------------------------------------------+\n```\n\n| Element | Position | Description |\n|---|---|---|\n| Hamburger menu | Left | Toggle button for mobile navigation drawer |\n| Logo | Center | Same as desktop but horizontally centered |\n| Cart icon | Right | Same as desktop |\n\n**Sticky Header (optional):**\n\nWhen enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.\n\n**Mobile Navigation Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Animation | Slides in from the left side |\n| Height | Full viewport height |\n| Backdrop | Semi-transparent dark overlay behind the drawer |\n| Close button | X icon at the top-right of the drawer |\n| Navigation items | Vertical stack with comfortable tap targets |\n| Submenus | Collapsible accordion pattern with chevron icons |\n| Account link | Positioned at the bottom of the navigation list |\n| Focus behavior | Focus is trapped within the drawer while open |\n| ARIA | Labeled as \"Mobile navigation\" |\n\n### 2.5 Main Content Area\n\nThe main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.\n\n### 2.6 Footer\n\nThe footer is a content-info landmark.\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |\n|                                                               |\n| [Social Icons]                                                |\n| [Copyright] [Payment Icons]                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from \"footer-menu\" navigation menu. Each column has an uppercase heading and a list of links |\n| Store info column | Store name, address (if configured), contact email |\n| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |\n| Copyright line | Small muted text: \"(c) {year} {store name}. All rights reserved.\" |\n| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |\n| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |\n| Background | Light gray (dark: very dark gray), top border separator |\n\n### 2.7 Cart Drawer (Livewire)\n\nThe cart drawer is always present in the layout as a global Livewire component. It listens for the \"cart-updated\" browser event to refresh and open. See Section 6 for full specification.\n\n### 2.8 Dark Mode\n\nDark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:\n\n| Mode | Implementation |\n|---|---|\n| System preference (default) | Follows OS-level dark mode preference automatically |\n| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |\n| Forced light/dark | Applied at server render time on the HTML element |\n\n**Color Token Patterns:**\n\n| Token | Light Mode | Dark Mode |\n|---|---|---|\n| Page background | White | Near-black |\n| Card/section background | Light gray | Very dark gray |\n| Heading text | Near-black | White |\n| Body text | Medium gray | Light gray |\n| Borders | Light gray | Medium-dark gray |\n| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |\n\n---\n\n## 3. Home Page (GET /)\n\nThe home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:\n\n1. Hero Banner\n2. Featured Collections\n3. Featured Products\n4. Newsletter Signup\n5. Rich Text\n\n### 3.1 Hero Banner Section\n\n**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.\n\n**Desktop (large breakpoint and above):**\n\n```\n+--------------------------------------------------------------+\n|                                                               |\n|  [Background Image / Gradient]                                |\n|                                                               |\n|       [Heading - very large, bold]                            |\n|       [Subheading - medium, regular weight]                   |\n|       [CTA Button - primary, large]                           |\n|                                                               |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |\n| Content | Centered vertically and horizontally, white text |\n| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |\n| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |\n| CTA | Styled as primary button, large. Links to a configured collection or product |\n| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |\n| Padding | Generous vertical and horizontal padding |\n\n**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.\n\n**Accessibility:**\n\n- If background is an image, it is decorative (the content provides meaning)\n- CTA button has descriptive text (e.g., \"Shop the summer collection\")\n\n### 3.2 Featured Collections Section\n\n**Layout:** Section with heading and grid of collection cards.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, large, bold, centered, with generous bottom margin |\n| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |\n| Count | 2 to 4 collections, configurable in theme settings |\n\n**Collection Card:**\n\n```\n+---------------------------+\n|  [Collection Image]       |\n|  3:4 aspect ratio         |\n|  cover-fit, rounded       |\n|                           |\n|  [Title overlay at bottom]|\n|  [Shop now ->]            |\n+---------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |\n| Title | White text overlaid at bottom of image, semi-bold, medium-large size |\n| \"Shop now\" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |\n| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |\n\n**Accessibility:**\n\n- Each card link has the collection title as accessible name\n- Images have alt text matching the collection title\n\n### 3.3 Featured Products Section\n\n**Livewire Component:** FeaturedProducts\n\nThis section uses Livewire for lazy loading to improve initial page load.\n\n| Attribute | Description |\n|---|---|\n| Section heading | H2, same styling as featured collections heading |\n| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |\n| Products | 4 to 8, configurable. Sourced from a configured collection or \"best sellers\" logic |\n| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |\n\nEach product uses the product-card component (see Section 16).\n\n### 3.4 Newsletter Signup Section\n\n**Livewire Component:** NewsletterSignup\n\n**Layout:**\n\n```\n+--------------------------------------------------------------+\n|  [Heading: \"Stay in the loop\"]                                |\n|  [Subtext: \"Subscribe for exclusive offers and updates.\"]     |\n|  [Email input] [Subscribe button]                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Background | Light gray (dark mode: darker gray), generous vertical padding |\n| Content | Centered, constrained to a comfortable width |\n| Heading | H2, medium-large, bold |\n| Subtext | Muted color, small top margin |\n| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |\n| Input | Email type, placeholder \"Enter your email\", required |\n| Button | Primary style, \"Subscribe\" text |\n| Success state | Replaces form with \"Thanks for subscribing!\" message |\n| Error state | Inline error below input in red, small text |\n| Loading state | Button text changes to \"Subscribing...\" during submission |\n\n**Accessibility:**\n\n- Label associated with email input (visually hidden if using placeholder)\n- Success/error messages in a polite live region for screen readers\n\n### 3.5 Rich Text Section\n\nSimple section rendering customizable content from theme settings.\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained to comfortable reading width, centered, vertical padding |\n| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |\n| Dark mode | Inverted prose styling |\n\n---\n\n## 4. Collection Page (GET /collections/{handle})\n\n**Livewire Component:** CollectionPage\n\n### 4.1 Collection Header\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > Collections > {Collection Title} |\n| Title | H1, large, bold |\n| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |\n| Spacing | Generous vertical padding |\n\n### 4.2 Toolbar\n\nHorizontal bar between the header and the product grid.\n\n**Desktop:**\n\n```\n+--------------------------------------------------------------+\n| [Filter toggle button]          [X products] [Sort dropdown]  |\n+--------------------------------------------------------------+\n```\n\n**Mobile:**\n\n```\n+--------------------------------------------------------------+\n| [Filter button]                           [Sort dropdown]     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Product count | Small muted text, e.g., \"24 products\" |\n| Sort dropdown | Select element with live Livewire binding |\n\n**Sort Options:**\n\n| Option | Description |\n|---|---|\n| Featured | Default sort order |\n| Price: Low to High | Ascending by price |\n| Price: High to Low | Descending by price |\n| Newest | Descending by creation date |\n| Best Selling | Descending by sales count |\n\n### 4.3 Filter Sidebar\n\n**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.\n**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by \"Filter\" button with filter icon.\n\n**Filter Groups:**\n\nEach filter group is a collapsible section with a heading and toggle chevron.\n\n| Filter Group | Type | Binding | Description |\n|---|---|---|---|\n| Availability | Single checkbox | Live | \"In stock\" toggle |\n| Price Range | Two number inputs | Live with 500ms debounce | \"Min\" and \"Max\" inputs with currency symbol prefix, displayed side by side |\n| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |\n| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |\n\n**Clear All:**\n\n- Link at the top of the filter sidebar: \"Clear all filters\"\n- Only visible when at least one filter is active\n- Small, blue, interactive text\n\n**Active Filter Pills:**\n\n- Displayed above the product grid (below toolbar) when filters are active\n- Each pill: rounded tag with filter value text and a remove (X) button\n- \"Clear all\" link at the end of the pills row\n\n**Mobile Filter Drawer:**\n\n| Attribute | Description |\n|---|---|\n| Position | Full-height overlay from left side |\n| Header | \"Filters\" title + close button (X) |\n| Content | Same filter groups as desktop, stacked vertically |\n| Footer | Sticky \"Apply\" button at bottom that closes the drawer |\n| Clear | \"Clear all\" link in header area |\n| Focus | Trapped within drawer while open |\n\n### 4.4 Product Grid\n\n| Attribute | Description |\n|---|---|\n| Layout | Fills remaining width next to the sidebar |\n| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |\n| Loading state | Subtle opacity reduction on the grid while results update |\n\n### 4.5 Product Card (Component)\n\nThe product card is a reusable component used on collection pages, search results, and featured products sections.\n\n```\n+---------------------------+\n|  [Product Image]          |\n|  1:1 aspect ratio         |\n|  cover-fit, rounded       |\n|  (hover: show 2nd image)  |\n|                           |\n|  [Sale badge]  [Sold out] |\n+---------------------------+\n|  [Product Title]          |\n|  [Price]  [Compare price] |\n|  [Quick Add / Options]    |\n+---------------------------+\n```\n\n**Image Behavior:**\n\n| Attribute | Description |\n|---|---|\n| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |\n| Primary image | Cover-fit, fills the container |\n| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |\n| Fallback | If no image, show a centered shopping bag placeholder icon |\n| Loading | Lazy-loaded |\n\n**Badges:**\n\n| Badge | Condition | Position |\n|---|---|---|\n| \"Sale\" | compare_at_amount > price_amount | Top-left of image, stacked vertically |\n| \"Sold out\" | All variants out of stock with deny policy | Top-left of image, stacked vertically |\n\n**Text Content:**\n\n| Element | Description |\n|---|---|\n| Title | H3 (or configurable heading level), small semi-bold text. Truncated to 2 lines |\n| Price | Current price in small semi-bold text. Compare-at price shown with strikethrough if applicable |\n| Quick Add | Single variant: \"Add to cart\" button. Multiple variants: \"Choose options\" link text. Hidden by default on desktop (shown on hover), always visible on mobile |\n\n**Accessibility:**\n\n- Card link wraps the entire card; title serves as the accessible name\n- Badges have screen reader labels (e.g., \"On sale\")\n- Price changes announced via parent live region when filtering changes results\n\n### 4.6 Pagination\n\n| Attribute | Description |\n|---|---|\n| Style | Numbered pagination with previous/next arrow buttons |\n| Current page | Highlighted with primary color background and white text |\n| Other pages | Muted text, hover background |\n| Disabled | Reduced opacity and not-allowed cursor on previous (at first page) or next (at last page) |\n| Truncation | Ellipsis for large page counts |\n| Mobile | Simplified: previous/next only with current page indicator |\n\n### 4.7 Empty State\n\nWhen no products match the current filters:\n\n```\n+--------------------------------------------------------------+\n|  [Search icon - large, muted]                                 |\n|  \"No products found\"                                          |\n|  \"Try adjusting your filters or browse our full collection.\"  |\n|  [Clear filters button]                                       |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Icon | Large, muted search icon |\n| Heading | Medium, semi-bold |\n| Subtext | Muted, descriptive guidance |\n| Button | Secondary style, \"Clear filters\" |\n| Position | Centered in the grid area with generous vertical padding |\n\n---\n\n## 5. Product Page (GET /products/{handle})\n\n**Livewire Component:** ProductPage\n\n### 5.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column, 50/50 split. Left column (images) is sticky while the right column (product info) scrolls.\n\n**Mobile:** Single column, stacked. Images first, then product info.\n\n```\nDesktop:\n+-------------------------------+-------------------------------+\n|  [Image Gallery]              |  [Product Info]               |\n|  (sticky while scrolling)     |  (scrollable)                 |\n+-------------------------------+-------------------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Image Gallery - horizontal scroll]                          |\n+--------------------------------------------------------------+\n|  [Product Info - full width]                                  |\n+--------------------------------------------------------------+\n```\n\nContainer constrained to standard max-width with horizontal padding and vertical spacing.\n\n### 5.2 Image Gallery (Left Column)\n\n**Desktop:**\n\n```\n+-------------------------------+\n|                               |\n|  [Main Image - large]         |\n|  1:1 aspect ratio, rounded    |\n|                               |\n+-------------------------------+\n| [Thumb] [Thumb] [Thumb] ...  |\n+-------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Main image | Square aspect ratio, large rounded corners, cover-fit. Gray placeholder background |\n| Zoom on hover | Optional (theme setting): image scales to 150% and follows cursor position |\n| Thumbnail strip | Horizontal row with overflow scroll, small gap between thumbnails |\n| Each thumbnail | 64x64px, rounded, cover-fit, pointer cursor. Active thumbnail has a colored border |\n| Variant images | When a variant is selected that has specific images, those images are shown |\n\n**Mobile:**\n\n| Attribute | Description |\n|---|---|\n| Gallery | Horizontal scrollable row with snap-scroll behavior |\n| Each image | Full-width, square aspect ratio, rounded, cover-fit |\n| Scroll indicators | Row of dots below the gallery. Active dot is dark (light in dark mode), inactive dots are gray |\n\n**Accessibility:**\n\n| Requirement | Description |\n|---|---|\n| Alt text | Main image has descriptive alt text from product media data |\n| Thumbnails | Rendered as buttons with labels \"View image X of Y\" and current state indicator |\n| Region | Gallery wrapped in a labeled region (\"Product images\") |\n\n### 5.3 Product Info (Right Column)\n\n#### Breadcrumbs\n\nHome > {Primary Collection} > {Product Title}. Small, muted text with separator characters.\n\n#### Product Title\n\nH1, large, bold.\n\n#### Price Display\n\n| Element | Description |\n|---|---|\n| Current price | Large, bold |\n| Compare-at price | Medium, muted, strikethrough (shown when set and higher than current price) |\n| Sale badge | Inline badge next to the price |\n| Currency | Formatted based on the store's default currency |\n| Live updates | Price container is a live region, so price changes when variant changes are announced to screen readers |\n\n#### Variant Selector\n\nFor each product option (e.g., Size, Color), render a selector group.\n\n**Radio button style (for options with 6 or fewer values):**\n\n```\nSize\n[S] [M] [L] [XL] [XXL]\n```\n\n| State | Visual Description |\n|---|---|\n| Available | Bordered pill shape, pointer cursor, border darkens on hover |\n| Selected | Primary color border with subtle background tint and focus ring |\n| Unavailable | Muted border, muted text, strikethrough, reduced opacity, not-allowed cursor |\n\n**Select dropdown style:** Used when the option has more than 6 values.\n\n**Color swatches (when option name is \"Color\"):**\n\n| Attribute | Description |\n|---|---|\n| Shape | Circular, 32x32px |\n| Color | Determined by value name mapped to a color lookup or inline style |\n| Selected state | Ring with offset around the swatch |\n| Tooltip | Shows color name on hover |\n\n**Livewire behavior:**\n\n- Selected options are a key-value map (e.g., Size = M, Color = Red)\n- Selected variant is computed by matching all selected options to the variant option values\n- On change: updates price, stock messaging, available images, and add-to-cart availability\n\n#### Stock Messaging\n\nDisplayed below the variant selector.\n\n| Condition | Message | Visual Style |\n|---|---|---|\n| In stock, quantity > 10 | \"In stock\" | Green text with check icon |\n| In stock, quantity <= 10 | \"Only X left in stock\" | Amber text with warning icon |\n| Out of stock, deny policy | \"Out of stock\" | Red text with X icon |\n| Out of stock, continue policy | \"Available on backorder\" | Blue text with info icon |\n\nSmall text, icon + text inline. Wrapped in a live region for screen reader announcements on variant change.\n\n#### Quantity Selector\n\nUses the quantity-selector component.\n\n```\n[-] [3] [+]\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Inline flex, bordered, rounded |\n| Decrease button | \"-\" icon, 40x40px. Disabled at minimum (1) |\n| Input | Centered number input, 56px wide, 40px tall. Min = 1, Max = available stock (deny policy) or unlimited (continue policy) |\n| Increase button | \"+\" icon, same size. Disabled at maximum |\n| Disabled state | Reduced opacity, not-allowed cursor |\n| Labels | Input labeled \"Quantity\", buttons labeled \"Decrease quantity\" / \"Increase quantity\" |\n\n#### Add to Cart Button\n\n| Attribute | Description |\n|---|---|\n| Width | Full width |\n| Style | Primary button: blue background, white text, semi-bold, generous padding, rounded |\n| Text | \"Add to cart\" |\n| Loading state | Text changes to \"Adding...\" with spinner icon. Button disabled during loading |\n| Sold out state | Gray background, muted text, not-allowed cursor. Text: \"Sold out\" |\n| Spacing | Generous top margin |\n\n**Livewire action (addToCart):**\n\n1. Validates variant is selected and in stock\n2. Adds to cart (creates cart if none exists in session)\n3. Dispatches \"cart-updated\" browser event\n4. Cart drawer opens automatically\n\n#### Product Description\n\n- Separated from above content by a horizontal rule with generous vertical spacing\n- Rendered HTML styled with prose/typography styling\n\n#### Tags\n\n- Displayed below the description (if tags exist)\n- Horizontal wrapping row with gap\n- Each tag: rounded pill, gray background, small text\n\n### 5.4 Accessibility Summary\n\n| Requirement | Implementation |\n|---|---|\n| Variant radio buttons | Properly labeled with labels and grouped with fieldset/legend |\n| Live regions | Price display, stock messaging, add-to-cart feedback |\n| Image alt text | All images have meaningful alt text |\n| Keyboard | Tab through thumbnails, variant options, quantity controls, and add-to-cart |\n| Focus indicators | Visible ring styles on all interactive elements |\n\n---\n\n## 6. Cart Drawer\n\n**Livewire Component:** CartDrawer\n\n### 6.1 Trigger and Animation\n\n| Attribute | Description |\n|---|---|\n| Trigger | \"cart-updated\" browser event (from add-to-cart actions), or clicking the cart icon in the header |\n| Animation | Slides in from the right side of the viewport. Transforms from fully off-screen to visible |\n| Backdrop | Fixed full-screen semi-transparent dark overlay. Click to close |\n| Panel | Fixed, right-aligned, full height, full width on small screens or 384px on larger screens. White background (dark mode: near-black), shadow |\n| Body | Scroll locked when drawer is open |\n\n### 6.2 Drawer Header\n\n```\n+--------------------------------------+\n| Your Cart (3)                    [X] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Heading | H2, semi-bold, medium-large text |\n| Item count | In parentheses after heading |\n| Close button | X icon, labeled \"Close cart\" |\n| Layout | Flex row, space-between, with horizontal padding and bottom border |\n\n### 6.3 Cart Line Items\n\nScrollable list area occupying the space between header and totals.\n\nEach line item:\n\n```\n+--------------------------------------+\n| [Thumb]  Title                       |\n|          Variant: Size M / Blue      |\n|          [-] [2] [+]       $49.98    |\n|                             [Remove] |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Thumbnail | 64x64px, rounded, cover-fit, non-shrinking |\n| Title | Small semi-bold text, truncated to 1 line |\n| Variant info | Extra-small muted text showing selected options |\n| Quantity stepper | Compact version of quantity-selector (smaller buttons, 32x32px) |\n| Line total | Small semi-bold text |\n| Remove button | Trash icon, muted color, turns red on hover. Labeled \"Remove {item title} from cart\" |\n| Separation | Bottom border between items, vertical padding per item |\n| Loading | Opacity reduction on the line item during quantity update |\n\n### 6.4 Discount Code Section\n\nBelow the line items list, above totals.\n\n**No discount applied:**\n\n```\n+--------------------------------------+\n| [Discount code input] [Apply]        |\n+--------------------------------------+\n```\n\n**Discount applied:**\n\n```\n+--------------------------------------+\n| SUMMER20 (-15%)              [Remove]|\n+--------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Input | Small text, placeholder \"Discount code\" |\n| Apply button | Secondary style, small |\n| Success state | Green text showing the code and discount type, with a remove button |\n| Error state | Small red text below, e.g., \"Invalid discount code\" or \"This code has expired\" |\n| Loading state | Button text changes to \"Applying...\" |\n\n### 6.5 Totals Section\n\n```\n+--------------------------------------+\n| Subtotal                     $149.97 |\n| Discount (SUMMER20)          -$22.50 |\n| ------------------------------------ |\n| Estimated total              $127.47 |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Position | Sticky at the bottom of the drawer, with top border |\n| Each line | Flex row, justified space-between, small text |\n| Discount line | Only shown when discount applied, green text |\n| Total | Slightly larger text, semi-bold |\n| Note | \"Shipping and taxes calculated at checkout\" in extra-small muted text below totals |\n\n### 6.6 Action Buttons\n\n| Button | Description |\n|---|---|\n| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |\n| Continue shopping | Centered text link below checkout button. Small muted text, hover to darker. Closes the drawer |\n\n### 6.7 Empty Cart State\n\n```\n+--------------------------------------+\n|                                      |\n|  [Shopping bag icon - large]         |\n|  \"Your cart is empty\"                |\n|  [Continue shopping button]          |\n|                                      |\n+--------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Centered vertically and horizontally in the drawer content area |\n| Icon | Large, very muted shopping bag icon |\n| Text | Muted, with top margin |\n| Button | Secondary style, closes the drawer |\n\n### 6.8 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Role | Dialog with modal behavior, labeled \"Shopping cart\" |\n| Focus trap | Tab/Shift+Tab cycles through drawer elements only |\n| On open | Focus moves to the close button or first interactive element |\n| On close | Focus returns to the element that triggered the drawer (cart icon) |\n| Escape key | Closes the drawer |\n| Feedback | All quantity changes and removals announced via polite live region |\n\n---\n\n## 7. Full Cart Page (GET /cart)\n\n**Livewire Component:** CartPage\n\nSame functionality as the cart drawer but in a full-page layout.\n\n### 7.1 Desktop Layout\n\n```\n+--------------------------------------------------------------+\n|  Your Cart                                                    |\n+--------------------------------------------------------------+\n|  Image | Product      | Price   | Quantity | Total   |       |\n|  ------|------------- |---------|----------|---------|-------|\n|  [img] | Title        | $24.99  | [-][2][+]| $49.98  | [X]   |\n|        | Size: M      |         |          |         |       |\n|  [img] | Title        | $34.99  | [-][1][+]| $34.99  | [X]   |\n|        | Color: Blue  |         |          |         |       |\n+--------------------------------------------------------------+\n|                                            Subtotal: $84.97   |\n|                              [Discount code] [Apply]          |\n|                                            Discount: -$12.75  |\n|                                            Total:    $72.22   |\n|                                                               |\n|                                            [Checkout button]  |\n|                                            [Continue shopping]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Container | Constrained max-width, centered, with horizontal padding and vertical spacing |\n| Title | H1, large bold, generous bottom margin |\n| Desktop layout | Proper table with header row and body |\n| Table headers | Left-aligned, small, semi-bold, muted, uppercase, wide tracking |\n| Mobile layout | Card-based layout (same as cart drawer items, stacked) |\n\n### 7.2 Totals and Actions\n\n- Right-aligned on desktop, full-width on mobile\n- Same discount code input/apply functionality as drawer\n- Same totals breakdown\n- \"Checkout\" button: primary, aligned right\n- \"Continue shopping\" link: below checkout button\n\n### 7.3 Empty State\n\nSame as drawer empty state but centered in the page content area with more vertical padding.\n\n---\n\n## 8. Checkout Page (GET /checkout/{checkoutId})\n\n**Livewire Component:** CheckoutPage\n\n### 8.1 Layout\n\n**Desktop (large breakpoint and above):** Two-column layout. Left column (wider, approx 60%) contains the form sections. Right column (narrower, approx 40%) contains the sticky order summary.\n\n**Mobile:** Single column. Order summary is a collapsible section at the top (collapsed by default, showing a toggle with \"Show order summary\" / \"Hide order summary\" + chevron + total amount).\n\n```\nDesktop:\n+-------------------------------+-------------------+\n|  [Checkout Form Sections]     | [Order Summary]   |\n|  (wider, ~60%)                | (narrower, ~40%)  |\n|                               | (sticky)          |\n+-------------------------------+-------------------+\n\nMobile:\n+--------------------------------------------------------------+\n|  [Order Summary - collapsible]                                |\n+--------------------------------------------------------------+\n|  [Checkout Form Sections]                                     |\n+--------------------------------------------------------------+\n```\n\n### 8.2 Checkout Steps\n\nThe checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.\n\n**Step State Behavior:**\n\n| State | Description |\n|---|---|\n| Completed | Shows summary of entered data with an \"Edit\" link |\n| Current | Expanded with the full form |\n| Future | Collapsed, muted, not interactive |\n\nEach step header shows the step number + title, e.g., \"1. Contact information\".\n\n#### Step 1 - Contact Information\n\n| Field | Description |\n|---|---|\n| Email | Full-width email input, required. Validated on blur |\n| Account link | \"Already have an account? Log in\" link below email, small blue text |\n| Continue button | Primary, below the form |\n\n#### Step 2 - Shipping Address\n\nUses the address-form component.\n\n**Fields:**\n\n| Field | Type | Required | Layout Width |\n|---|---|---|---|\n| First name | Text | Yes | Half |\n| Last name | Text | Yes | Half |\n| Address line 1 | Text | Yes | Full |\n| Address line 2 | Text | No | Full |\n| City | Text | Yes | Half |\n| State/Province | Select or text | Yes (varies by country) | Half |\n| Postal code | Text | Yes | Half |\n| Country | Select | Yes | Half |\n| Phone | Tel | No | Full |\n\n- Layout: 2-column grid, full-width fields span both columns\n- Country select: on change, State/Province updates to show relevant regions\n- All fields validate on blur\n\n**Billing address:**\n\n- Checkbox: \"Billing address same as shipping\" (checked by default)\n- When unchecked, a second address form appears for billing\n\n**Saved addresses (logged-in customers):**\n\n- Dropdown above the form: \"Select a saved address\" with saved addresses listed\n- \"Use a new address\" option to show the blank form\n- On selection, form fields populate with the saved address data\n\nContinue button: primary.\n\n#### Step 3 - Shipping Method\n\nRadio button list of available shipping methods, filtered server-side by matching the shipping address to shipping zones.\n\n```\n+--------------------------------------------------------------+\n| (o) Standard Shipping          5-7 business days       $7.99 |\n| ( ) Express Shipping           2-3 business days      $14.99 |\n| ( ) Free Shipping              7-10 business days      Free  |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Each option | Bordered, rounded card with padding. Selected: primary border with subtle background tint. Unselected: gray border |\n| Label layout | Method name (left), estimated delivery (center), price (right) |\n| Live binding | On selection, the order summary sidebar updates totals |\n| No methods | Warning message: \"No shipping methods are available for your address. Please verify your address or contact us.\" in amber text with info icon |\n\nContinue button: primary.\n\n#### Step 4 - Payment Method & Pay\n\nAll payment is handled onsite via the Mock PSP. No external redirects, no iframes, no third-party scripts.\n\n**Payment method selection (radio buttons):**\n\n```\n+--------------------------------------------------------------+\n| Select a payment method                                       |\n|                                                               |\n| (o) Credit Card                                               |\n| ( ) PayPal                                                    |\n| ( ) Bank Transfer                                             |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Layout | Radio button list, each option in a bordered card (same styling as shipping method radios) |\n| Default | First option (Credit Card) is pre-selected |\n| Live binding | On selection, the payment form below updates to show the relevant fields |\n\n**Credit Card form (shown when Credit Card is selected):**\n\n```\n+--------------------------------------------------------------+\n| Card number     [4242 4242 4242 4242        ]                |\n| Cardholder name [John Doe                   ]                |\n| Expiry          [12/28     ]  CVC [123    ]                  |\n+--------------------------------------------------------------+\n| [Pay now - 29.98 EUR]                                        |\n+--------------------------------------------------------------+\n```\n\n| Field | Type | Required | Validation |\n|---|---|---|---|\n| Card number | Text (digits, auto-formatted with spaces every 4 digits) | Yes | 16 digits |\n| Cardholder name | Text | Yes | Max 255 |\n| Expiry | Text (MM/YY format) | Yes | Valid future date |\n| CVC | Text (3-4 digits) | Yes | 3-4 digits |\n\n**PayPal form (shown when PayPal is selected):**\n\n```\n+--------------------------------------------------------------+\n| You will be redirected to complete your PayPal payment.       |\n| (mock: payment is processed instantly)                        |\n|                                                               |\n| [Pay with PayPal - 29.98 EUR]                                |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Pay with PayPal - {total}\".\n\n**Bank Transfer form (shown when Bank Transfer is selected):**\n\n```\n+--------------------------------------------------------------+\n| After placing your order, you will receive bank transfer      |\n| instructions. Your order will be held for 7 days while we     |\n| await your payment.                                           |\n|                                                               |\n| [Place order - 29.98 EUR]                                     |\n+--------------------------------------------------------------+\n```\n\nNo additional form fields. The button text changes to \"Place order - {total}\" (not \"Pay now\", since payment is deferred).\n\n| Element | Description |\n|---|---|\n| Pay/Place button | Primary, full-width, large text. Loading state: \"Processing...\" with spinner. Calls `POST /checkouts/{id}/pay` with the selected method and card details (if credit card). |\n| Error handling | On payment decline (magic card number), display an inline error callout: \"Payment declined: {reason}\". The customer can correct details and retry. |\n| Success (card/paypal) | On successful instant capture, redirect to confirmation page. |\n| Success (bank_transfer) | On successful order placement, redirect to confirmation page (which shows bank transfer instructions). |\n\n### 8.3 Order Summary Sidebar (Right Column)\n\nSticky on desktop.\n\n```\n+-------------------------------+\n|  Order Summary                |\n+-------------------------------+\n|  [Thumb] Title x2     $49.98 |\n|          Size: M              |\n|  [Thumb] Title x1     $34.99 |\n+-------------------------------+\n|  [Discount code] [Apply]     |\n+-------------------------------+\n|  Subtotal              $84.97|\n|  Discount             -$12.75|\n|  Shipping               $7.99|\n|  Tax                    $5.74|\n|  ----------------------------|\n|  Total                 $85.95|\n+-------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Background | Light gray (dark: dark gray), large rounded corners, with padding |\n| Cart items | Compact list with small thumbnail (48x48px), truncated title, quantity badge, and line total |\n| Discount code input | Same as cart drawer |\n| Totals | Line items for subtotal, discount (conditional), shipping (\"Calculated at next step\" until selected), tax (calculated after address), and total |\n| Updates | Event-driven from step completion actions (no polling) |\n\n### 8.4 Accessibility\n\n| Requirement | Description |\n|---|---|\n| Labels | All form inputs have associated label elements |\n| Validation errors | Linked via describedby pointing to error message element |\n| Required fields | Marked with required attribute and visual asterisk |\n| Step transitions | Focus moves to the first input of the newly expanded step |\n| Shipping radios | Grouped in a fieldset with legend |\n| Totals | Live region for screen reader updates |\n\n---\n\n## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)\n\n### 9.1 Layout\n\nCentered content, single column.\n\n```\n+--------------------------------------------------------------+\n|  [Checkmark icon - green circle]                              |\n|  \"Thank you for your order!\"                                  |\n|  Order #1042                                                  |\n|  \"We've sent a confirmation to you@email.com\"                 |\n+--------------------------------------------------------------+\n|  Order Summary                                                |\n|  [Items list with thumbnails]                                 |\n+--------------------------------------------------------------+\n|  Shipping Address          |  Payment Method                  |\n|  Jane Doe                  |  Credit Card ending in 4242      |\n|  123 Main St               |  (or: PayPal / Bank Transfer)    |\n|  New York, NY 10001        |                                  |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  [Continue shopping button]  [View order - if logged in]      |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained to a comfortable width, centered, generous vertical padding |\n| Success icon | Large green checkmark in a circle, centered |\n| Heading | H1, large bold, centered, generous top margin |\n| Order number | Medium muted text, centered |\n| Email notice | Small muted text, centered |\n\n### 9.2 Order Details\n\n| Element | Description |\n|---|---|\n| Items | List with thumbnail, title, variant info, quantity, price |\n| Address and payment | Two-column grid on desktop, stacked on mobile |\n| Payment method display | Shows \"Credit Card ending in {last4}\", \"PayPal\", or \"Bank Transfer\" depending on `order.payment_method` |\n| Totals | Right-aligned summary |\n\n#### Bank Transfer Instructions (shown only when `payment_method = bank_transfer`)\n\nA prominent callout (info variant) displayed between the order details and the totals:\n\n```\n+--------------------------------------------------------------+\n|  [info icon] Bank Transfer Instructions                       |\n|                                                               |\n|  Please transfer the total amount to the following account:   |\n|                                                               |\n|  Bank:     Mock Bank AG                                       |\n|  IBAN:     DE89 3704 0044 0532 0130 00                        |\n|  BIC:      COBADEFFXXX                                        |\n|  Amount:   29.98 EUR                                          |\n|  Reference: #1042                                             |\n|                                                               |\n|  Please complete your transfer within 7 days. Your order will |\n|  be processed once payment is confirmed by our team.          |\n+--------------------------------------------------------------+\n```\n\nThe reference field uses the order number so the admin can match incoming transfers.\n\n### 9.3 Actions\n\n| Button | Description |\n|---|---|\n| Continue shopping | Primary, links to home page |\n| View order | Link to account order detail page (logged-in customers only) |\n| Layout | Centered row with gap |\n\n---\n\n## 10. Customer Account Pages\n\nAll account pages require authentication. Unauthenticated access redirects to the login page.\n\n### 10.1 Login (GET /account/login)\n\n**Livewire Component:** Login\n\n```\n+--------------------------------------------------------------+\n|  Log in to your account                                       |\n|                                                               |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Forgot password? link]                                      |\n|  [Log in button]                                              |\n|                                                               |\n|  Don't have an account? [Create one]                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, autocomplete for current password |\n| Forgot password | Small blue link, right-aligned |\n| Submit button | Primary, full-width, \"Log in\" |\n| Error messages | Inline below fields in small red text |\n| General error | Invalid credentials shown in a callout/alert above the form |\n| Register link | \"Create one\" link to registration page |\n\n### 10.2 Register (GET /account/register)\n\n**Livewire Component:** Register\n\n```\n+--------------------------------------------------------------+\n|  Create an account                                            |\n|                                                               |\n|  [Name input]                                                 |\n|  [Email input]                                                |\n|  [Password input]                                             |\n|  [Confirm password input]                                     |\n|  [ ] Subscribe to marketing emails                            |\n|  [Create account button]                                      |\n|                                                               |\n|  Already have an account? [Log in]                            |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Narrow max-width, centered, generous vertical padding |\n| Heading | H1, medium-large, bold, centered |\n| Form | Vertical stack with gap |\n| Name | Text type, required, autocomplete for name |\n| Email | Email type, required, autocomplete for email |\n| Password | Password type, required, minimum 8 characters, autocomplete for new password |\n| Confirm password | Password type, required, autocomplete for new password |\n| Marketing checkbox | Checkbox with label \"Subscribe to marketing emails\" |\n| Submit button | Primary, full-width, \"Create account\" |\n| On success | Redirect to account dashboard |\n\n### 10.3 Account Dashboard (GET /account)\n\n```\n+--------------------------------------------------------------+\n|  Welcome back, Jane!                                          |\n+--------------------------------------------------------------+\n|  [Orders card]     [Addresses card]     [Log out card]        |\n+--------------------------------------------------------------+\n|  Recent Orders                                                |\n|  # | Date       | Status  | Total   | Action                 |\n|  --|------------|---------|---------|--------                 |\n|  1042 | Jan 15  | Paid    | $98.70  | View                   |\n|  1041 | Jan 10  | Shipped | $45.00  | View                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Container | Constrained max-width, centered, with padding |\n| Welcome heading | H1, medium-large bold |\n| Quick links | Responsive grid: 1 column on mobile, 3 on tablet+. Each card has border, rounded corners, padding, hover shadow |\n| Orders card | Order icon, \"Order history\", \"View all your orders\" |\n| Addresses card | Location icon, \"Addresses\", \"Manage your addresses\" |\n| Log out card | Logout icon, \"Log out\", submits POST logout form |\n| Recent orders | Table showing last 5 orders. Status shown as badge component |\n\n### 10.4 Order History (GET /account/orders)\n\n| Attribute | Description |\n|---|---|\n| Heading | H1, \"Order History\" |\n| Table columns | Order (link), Date, Status (badge), Total, Action (\"View\" link) |\n| Status badge colors | Pending: yellow. Paid: green. Fulfilled: blue. Cancelled: gray. Refunded: red |\n| Mobile | Card-based layout instead of table. Each card shows order number, date, status, and total |\n| Pagination | Standard pagination component |\n\n### 10.5 Order Detail (GET /account/orders/{orderNumber})\n\n```\n+--------------------------------------------------------------+\n|  Order #1042                      Status: [Paid] [Fulfilled] |\n|  Placed on January 15, 2026                                  |\n+--------------------------------------------------------------+\n|  Items                                                        |\n|  [Thumb] Product Title x2              $49.98                 |\n|          Size: M                                              |\n+--------------------------------------------------------------+\n|  Shipping Address    |  Billing Address   | Payment           |\n|  Jane Doe            |  Same as shipping  | Visa ****4242     |\n|  123 Main St         |                    |                   |\n+--------------------------------------------------------------+\n|  Subtotal        $84.97                                       |\n|  Shipping         $7.99                                       |\n|  Tax              $5.74                                       |\n|  Discount        -$0.00                                       |\n|  Total           $98.70                                       |\n+--------------------------------------------------------------+\n|  Fulfillment                                                  |\n|  Shipped via UPS - 1Z999AA10123456784                         |\n|  [Track shipment ->]                                          |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Account > Orders > #1042 |\n| Header | Order number and status badges |\n| Date | Small muted text |\n| Items | Table with thumbnail, title + variant info, quantity, line total |\n| Info grid | Three columns on desktop (shipping, billing, payment), stacked on mobile |\n| Totals | Summary breakdown |\n| Fulfillment | Only shown if fulfillments exist. Shows tracking company, number, and link to tracking URL (opens in new tab safely) |\n\n### 10.6 Address Book (GET /account/addresses)\n\n**Livewire Component:** AddressBook\n\n```\n+--------------------------------------------------------------+\n|  Your Addresses                          [+ Add new address]  |\n+--------------------------------------------------------------+\n|  [Default badge]              |                               |\n|  Jane Doe                     |  Jane Doe                     |\n|  123 Main St                  |  456 Oak Ave                  |\n|  New York, NY 10001           |  Chicago, IL 60601            |\n|  United States                |  United States                |\n|  [Edit] [Delete]              |  [Edit] [Delete] [Set default]|\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Grid | 1 column on mobile, 2 on tablet, 3 on desktop |\n| Each card | Bordered, rounded, with padding. Default address has a primary-colored border and \"Default\" badge |\n| Content | Formatted address lines |\n| Actions | Edit and Delete text buttons, with \"Set as default\" on non-default addresses |\n| Add new | Opens modal with address-form component |\n| Edit | Opens same modal pre-filled |\n| Delete | Confirmation dialog before deleting |\n\n---\n\n## 11. Search\n\n### 11.1 Search Modal\n\n**Livewire Component:** SearchModal\n\nTriggered by clicking the search icon in the header.\n\n```\n+--------------------------------------------------------------+\n|  [Search input with icon]                           [X close] |\n+--------------------------------------------------------------+\n|  Products                                                     |\n|  [Thumb] Product Title            $24.99                      |\n|  [Thumb] Product Title            $34.99                      |\n|                                                               |\n|  Collections                                                  |\n|  Summer Collection                                            |\n|  Winter Collection                                            |\n|                                                               |\n|  [View all X results ->]                                      |\n+--------------------------------------------------------------+\n```\n\n| Attribute | Description |\n|---|---|\n| Overlay | Full-screen semi-transparent dark backdrop |\n| Modal | Centered, moderate max-width, rounded corners, shadow |\n| Search input | Large text, search icon prefix, autofocus on open. Live binding with 300ms debounce |\n| Results | Appear below input as user types. Grouped by type: \"Products\" and \"Collections\" headings |\n| Product results | Thumbnail, title, price. Clickable to product page |\n| Collection results | Title only. Clickable to collection page |\n| Max results | 5 per category in the modal |\n| View all | Link at the bottom navigating to the full search results page |\n| Loading | Skeleton lines while fetching |\n| No results | \"No results for '{query}'\" message |\n| Keyboard | Escape closes, arrow keys navigate results, Enter selects highlighted result |\n| Focus | Trapped within the modal |\n| ARIA | Dialog role, modal, labeled \"Search\". Results list as listbox with option items |\n\n### 11.2 Search Results Page (GET /search?q=)\n\n**Livewire Component:** SearchResults\n\nSame layout as the collection page (Section 4) with the following differences:\n\n| Difference | Description |\n|---|---|\n| Heading | H1, \"X results for '{query}'\" or \"Search results for '{query}'\" |\n| Breadcrumbs | Home > Search results |\n| Filters | Same filter sidebar (availability, price, type, vendor) |\n| Sort | Same sort dropdown |\n| Product grid | Same product card component |\n| Pagination | Same pagination |\n| Empty state | \"No results found for '{query}'. Try a different search term.\" with a search input to try again |\n\n---\n\n## 12. Content Pages (GET /pages/{handle})\n\n### 12.1 Layout\n\n| Element | Description |\n|---|---|\n| Breadcrumbs | Home > {Page Title} |\n| Title | H1, large bold |\n| Body | Rendered HTML from body_html, styled with prose/typography styling, constrained to comfortable reading width (configurable to full width via theme settings) |\n| Container | Standard max-width, centered, with padding |\n\n---\n\n## 13. Error Pages\n\n### 13.1 404 Page\n\n```\n+--------------------------------------------------------------+\n|  [404 - large, bold, very muted]                              |\n|  \"Page not found\"                                             |\n|  \"The page you're looking for doesn't exist or has been       |\n|   moved.\"                                                     |\n|                                                               |\n|  [Search input]                                               |\n|  [Go to home page button]                                     |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered vertically and horizontally |\n| 404 text | Very large, bold, extremely muted (barely visible as a background element) |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Search | Simple input + button, submits to search page |\n| Home link | Secondary button |\n\n### 13.2 503 / Maintenance Page\n\n```\n+--------------------------------------------------------------+\n|  [Store logo]                                                 |\n|  \"We'll be back soon\"                                         |\n|  \"We're currently performing maintenance. Please check back   |\n|   shortly.\"                                                   |\n+--------------------------------------------------------------+\n```\n\n| Element | Description |\n|---|---|\n| Layout | Full viewport height, centered column |\n| Logo | Standard height, centered |\n| Heading | Medium-large, bold |\n| Subtext | Muted, moderate max-width, centered |\n| Style | Consistent with the store theme (colors, fonts) |\n\n---\n\n## 14. Responsive Breakpoints\n\nDefault breakpoints used across the storefront:\n\n| Breakpoint | Min-width | Typical Devices |\n|---|---|---|\n| Base (none) | 0px | Mobile phones (portrait) |\n| Small | 640px | Mobile phones (landscape), small tablets |\n| Medium | 768px | Tablets (portrait) |\n| Large | 1024px | Tablets (landscape), small desktops |\n| Extra-large | 1280px | Desktops |\n\n### Key Responsive Behaviors\n\n| Component | Mobile (base) | Tablet (medium) | Desktop (large+) |\n|---|---|---|---|\n| Navigation | Hamburger + drawer | Hamburger + drawer | Horizontal nav bar |\n| Product grid | 2 columns | 3 columns | 3-4 columns |\n| Product page | Stacked (images, then info) | Stacked | Side-by-side 50/50 |\n| Cart drawer | Full-screen overlay | 384px sidebar | 384px sidebar |\n| Checkout | Single column, summary collapsed | Single column | Two columns (form + summary) |\n| Footer | Stacked columns | 2-column grid | 3-4 column grid |\n| Collection filters | Drawer overlay | Drawer overlay | Persistent sidebar |\n| Account tables | Card-based layout | Card-based layout | Table layout |\n\n### Touch Considerations\n\n| Consideration | Requirement |\n|---|---|\n| Tap targets | Minimum 44x44px for all interactive elements on mobile |\n| Hover effects | Only applied on devices with hover capability |\n| Swipe gestures | Horizontal swipe on product images (mobile gallery), cart drawer close |\n\n---\n\n## 15. Accessibility Checklist\n\n### Global (All Pages)\n\n| Requirement | Description |\n|---|---|\n| Skip link | \"Skip to main content\" link, first element in body |\n| Landmark roles | Banner for header, main for content, contentinfo for footer, labeled nav elements |\n| Heading hierarchy | Single H1 per page, logical H2-H6 nesting |\n| Focus indicators | Visible ring on all interactive elements with offset |\n| Color contrast | Minimum 4.5:1 for normal text, 3:1 for large text (WCAG AA) |\n| Keyboard navigation | All interactive elements reachable via Tab, operable via Enter/Space |\n| Screen reader text | Visually hidden text for icon-only buttons (close, cart, search) |\n| Language | Lang attribute on HTML element |\n| Images | All images have alt attributes; decorative images marked appropriately |\n\n### Page-Specific Accessibility\n\n| Page | Requirements |\n|---|---|\n| Collection Page | Live region for product count/grid on filter change. Filter sidebar labeled as navigation. Sort dropdown has associated label |\n| Product Page | Variant selector uses fieldset/legend grouping. Price and stock in live regions. Image gallery thumbnails as buttons with current state |\n| Cart Drawer | Dialog role with modal behavior, labeled \"Shopping cart\". Focus trap. Quantity changes in live region |\n| Checkout | Form validation with describedby linking to errors, invalid state on fields. Focus management on step transitions. Shipping radios in fieldset/legend. Totals in live region |\n| Search Modal | Dialog role with modal behavior. Results as listbox with option items. Active descendant tracking for keyboard navigation |\n| Account Pages | All form inputs labeled. Tables use proper header markup with column scope. Status badges readable by text (not color-only) |\n\n### Keyboard Interactions\n\n| Component | Key | Action |\n|---|---|---|\n| Hamburger menu | Enter/Space | Toggle open/close |\n| Mobile nav drawer | Escape | Close |\n| Dropdown menus | Arrow Up/Down | Navigate items |\n| Dropdown menus | Escape | Close |\n| Cart drawer | Escape | Close |\n| Search modal | Escape | Close |\n| Search results | Arrow Up/Down | Navigate results |\n| Search results | Enter | Select highlighted result |\n| Variant radio buttons | Arrow Left/Right | Navigate options |\n| Quantity input | Arrow Up/Down | Increment/decrement |\n| Modals/dialogs | Tab/Shift+Tab | Cycle focus within (trap) |\n\n---\n\n## 16. Component Library\n\n### storefront-layout\n\nMaster layout wrapper.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| title | string | App name from config | Page title |\n| metaDescription | string | Empty | Meta description tag |\n\n### storefront-product-card\n\nRenders a single product card in grid contexts.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| product | Product model | Required | Product with variants, media, and inventory eager-loaded |\n| headingLevel | string | h3 | Heading tag for the product title |\n| showQuickAdd | boolean | true | Whether to show the quick add / choose options action |\n\n**Eager-load requirements:** product.variants.inventoryItem, product.media (sorted by position).\n\n### storefront-price\n\nDisplays a formatted price with optional compare-at price.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| amount | integer | Required | Price in minor units (cents) |\n| currency | string | USD | ISO 4217 currency code |\n| compareAtAmount | integer or null | null | Original price in minor units for sale display |\n\n**Rendering:** Formats amount using Laravel's Number currency helper. If compareAtAmount is set and greater than amount, shows compare-at price with strikethrough styling and a \"Sale\" badge.\n\n### storefront-badge\n\nRenders a styled badge/tag.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| text | string | Required | Badge text |\n| variant | string | default | Visual variant: sale, sold-out, new, default |\n\n**Variant Styles:**\n\n| Variant | Background | Text Color |\n|---|---|---|\n| sale | Light red (dark: dark red tint) | Red |\n| sold-out | Light gray (dark: dark gray) | Medium gray |\n| new | Light blue (dark: dark blue tint) | Blue |\n| default | Light gray (dark: dark gray) | Dark gray |\n\n**Base appearance:** Inline pill shape, rounded-full, extra-small medium-weight text with horizontal padding.\n\n### storefront-quantity-selector\n\nNumber input with increment/decrement buttons.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| value | integer | 1 | Current quantity |\n| min | integer | 1 | Minimum allowed value |\n| max | integer or null | null | Maximum allowed value (null = unlimited) |\n| wireModel | string | Required | Livewire model binding string |\n| compact | boolean | false | Smaller variant for cart drawer |\n\n### storefront-address-form\n\nRenders a full address form with all fields.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| address | array or null | null | Pre-filled address data |\n| prefix | string | Empty | Livewire model prefix (e.g., \"shipping\" for shipping.firstName) |\n\n### storefront-order-summary\n\nRenders the checkout order summary sidebar.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |\n| showDiscountInput | boolean | true | Whether to show the discount code input |\n\n### storefront-breadcrumbs\n\nRenders a breadcrumb navigation trail.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| items | array | Required | Array of objects with label and url. Last item has no URL (current page) |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Breadcrumb\"\n- Ordered list with separator characters between items\n- Current page (last item) has a \"current page\" indicator and distinct styling\n- Includes structured data (BreadcrumbList schema) for SEO\n\n### storefront-pagination\n\nWraps Laravel's pagination with custom styling.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| paginator | LengthAwarePaginator | Required | Laravel paginator instance |\n\n**Rendering:**\n\n- Wrapped in a nav element labeled \"Pagination\"\n- Previous/Next buttons with arrow icons\n- Numbered page buttons with current page highlighted\n- Ellipsis for truncated ranges\n- Mobile: previous/next only with \"Page X of Y\" text\n\n---\n\n## 17. Livewire Component Summary\n\n| Component | Location | Purpose |\n|---|---|---|\n| FeaturedProducts | Home page | Lazy-loaded featured products grid |\n| NewsletterSignup | Home page, footer | Email subscription form |\n| CollectionPage | Collection page | Filtering, sorting, pagination |\n| ProductPage | Product page | Variant selection, add to cart |\n| CartDrawer | Layout (global) | Slide-in cart with line management |\n| CartPage | Cart page | Full-page cart |\n| CheckoutPage | Checkout | Multi-step checkout flow |\n| SearchModal | Layout (global) | Autocomplete search overlay |\n| SearchResults | Search page | Full search results with filters |\n| Login | Login page | Authentication form |\n| Register | Register page | Registration form |\n| AddressBook | Addresses page | CRUD for saved addresses |\n\n---\n\n## 18. Browser Event Communication\n\nLivewire components communicate via dispatched browser events:\n\n| Event Name | Dispatched By | Listened By | Payload |\n|---|---|---|---|\n| cart-updated | ProductPage, CartDrawer, CartPage | CartDrawer (opens + refreshes), Header (badge count) | cartId, itemCount |\n| open-cart-drawer | Header cart icon click | CartDrawer | None |\n| close-cart-drawer | CartDrawer close actions | CartDrawer | None |\n| open-search-modal | Header search icon click | SearchModal | None |\n| close-search-modal | SearchModal close actions | SearchModal | None |\n| variant-changed | ProductPage (variant selector) | ProductPage (internal) | variantId, price, stock |\n\n---\n\n## 19. SEO and Structured Data\n\n### Meta Tags\n\nEach page type sets appropriate meta tags:\n\n| Page | Title Pattern | Description Source |\n|---|---|---|\n| Home | Store name | Theme settings |\n| Collection | \"{Collection title} - {Store name}\" | Collection description (stripped of markup) |\n| Product | \"{Product title} - {Store name}\" | Product description (stripped, truncated to 160 chars) |\n| Content page | \"{Page title} - {Store name}\" | Page body (stripped, truncated) |\n| Search | \"Search results - {Store name}\" | Static |\n\n### Open Graph Tags\n\nProduct pages include Open Graph tags for social sharing:\n\n| Tag | Value |\n|---|---|\n| og:title | Product title |\n| og:description | Meta description |\n| og:image | Primary product image URL |\n| og:type | product |\n| og:url | Current page URL |\n| product:price:amount | Formatted price (dollars) |\n| product:price:currency | Store currency code |\n\n### JSON-LD Structured Data\n\n| Schema Type | Used On |\n|---|---|\n| Product | Product pages |\n| BreadcrumbList | Breadcrumb navigation on all pages |\n| Organization | Footer (store info) |\n\n---\n\n## 20. User Flow Diagrams\n\n### Browse to Purchase Flow\n\n```mermaid\nflowchart TD\n    A[Home Page] --> B{Browse}\n    B --> C[Collection Page]\n    B --> D[Search Modal]\n    D --> E[Search Results Page]\n    C --> F[Product Page]\n    E --> F\n    F --> G{Select Variant}\n    G --> H[Set Quantity]\n    H --> I[Add to Cart]\n    I --> J[Cart Drawer Opens]\n    J --> K{Continue?}\n    K -->|Continue Shopping| B\n    K -->|Apply Discount| L[Enter Discount Code]\n    L --> J\n    K -->|Checkout| M[Checkout Page]\n    M --> N[Step 1: Contact Info]\n    N --> O[Step 2: Shipping Address]\n    O --> P[Step 3: Shipping Method]\n    P --> Q[Step 4: Payment Method & Pay]\n    Q --> S[Order Confirmation]\n    S --> T{Next?}\n    T -->|Continue Shopping| A\n    T -->|View Order| U[Account Order Detail]\n```\n\n### Account Management Flow\n\n```mermaid\nflowchart TD\n    A[Login Page] --> B{Has Account?}\n    B -->|No| C[Register Page]\n    C --> D[Account Dashboard]\n    B -->|Yes| D\n    D --> E{Action}\n    E --> F[Order History]\n    F --> G[Order Detail]\n    G --> H[Track Shipment]\n    E --> I[Address Book]\n    I --> J{Action}\n    J --> K[Add Address]\n    J --> L[Edit Address]\n    J --> M[Delete Address]\n    J --> N[Set Default Address]\n    E --> O[Log Out]\n```\n\n### Cart Management Flow\n\n```mermaid\nflowchart TD\n    A[Add to Cart] --> B[Cart Drawer Opens]\n    B --> C{Action}\n    C --> D[Update Quantity]\n    D --> B\n    C --> E[Remove Item]\n    E --> F{Cart Empty?}\n    F -->|Yes| G[Empty Cart State]\n    G --> H[Continue Shopping]\n    F -->|No| B\n    C --> I[Apply Discount Code]\n    I --> J{Valid?}\n    J -->|Yes| K[Discount Applied]\n    K --> B\n    J -->|No| L[Error Message]\n    L --> B\n    C --> M[Proceed to Checkout]\n    M --> N[Checkout Page]\n    C --> O[View Full Cart Page]\n    O --> N\n```\n\n### Component State Diagram\n\n```mermaid\nstateDiagram-v2\n    [*] --> Empty: Cart initialized\n\n    state \"Cart Drawer\" as CD {\n        Empty --> HasItems: Add item\n        HasItems --> HasItems: Update quantity\n        HasItems --> HasItems: Apply discount\n        HasItems --> Empty: Remove last item\n        HasItems --> Checkout: Proceed to checkout\n    }\n\n    state \"Product Page\" as PP {\n        NoVariant --> VariantSelected: Select options\n        VariantSelected --> InStock: Stock > 0\n        VariantSelected --> OutOfStock: Stock = 0\n        InStock --> Adding: Click add to cart\n        Adding --> InStock: Complete\n        OutOfStock --> SoldOut: Deny policy\n        OutOfStock --> Backorder: Continue policy\n    }\n\n    state \"Checkout\" as CO {\n        Contact --> ShippingAddress: Complete step 1\n        ShippingAddress --> ShippingMethod: Complete step 2\n        ShippingMethod --> Payment: Complete step 3\n        Payment --> Processing: Submit payment\n        Processing --> Confirmation: Payment success\n        Processing --> Payment: Payment failure\n    }\n```\n\n---\n\n## 21. Flash / Toast Messages\n\n| Action | Message | Type |\n|--------|---------|------|\n| Item added to cart | \"Added to cart\" | success |\n| Item removed from cart | \"Item removed\" | success |\n| Cart is empty | \"Your cart is empty\" | info |\n| Discount applied | \"Discount applied\" | success |\n| Invalid discount code | \"Invalid discount code\" | error |\n| Out of stock | \"This product is currently out of stock\" | warning |\n| Registration success | \"Account created successfully\" | success |\n| Login success | (redirect, no message) | - |\n| Login failed | \"Invalid credentials\" | error |\n| Password reset sent | \"We have emailed your password reset link!\" | success |\n| Order placed | \"Thank you for your order!\" | success |\n","structuredPatch":[{"oldStart":1084,"oldLines":8,"newStart":1084,"newLines":7,"lines":[" "," ```"," +--------------------------------------------------------------+","-| You will be redirected to complete your PayPal payment.       |","-| (mock: payment is processed instantly)                        |","+| Your PayPal payment will be processed securely.               |"," |                                                               |"," | [Pay with PayPal - 29.98 EUR]                                |"," +--------------------------------------------------------------+"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"a4722e7b-0804-4735-bcf4-e5bae6ae734b"}
{"parentUuid":"2bd87948-8a88-4ebd-8694-603b4a960435","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_015xZRoRsfEcJRMDhdsV1uoc","type":"message","role":"assistant","content":[{"type":"text","text":"All 10 findings are fixed. Let me do a quick sweep to confirm no stale `payment_pending` references remain in the specs."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":592,"cache_read_input_tokens":67482,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":592},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRUxcqjhyrTVsxpHP1","type":"assistant","uuid":"6f7228a6-d834-4169-a551-0516c4f43860","timestamp":"2026-02-11T15:46:53.903Z"}
{"parentUuid":"6f7228a6-d834-4169-a551-0516c4f43860","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_015xZRoRsfEcJRMDhdsV1uoc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014q2g8JCRfuAmumLCcoQdZp","name":"Grep","input":{"pattern":"payment_pending","path":"/Users/fabianwesner/Herd/shop/specs","output_mode":"content"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":592,"cache_read_input_tokens":67482,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":592},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRUxcqjhyrTVsxpHP1","type":"assistant","uuid":"dad85aab-3520-406d-9612-e79ea49f756b","timestamp":"2026-02-11T15:46:54.691Z"}
{"parentUuid":"dad85aab-3520-406d-9612-e79ea49f756b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_014q2g8JCRfuAmumLCcoQdZp","toolUseID":"toolu_014q2g8JCRfuAmumLCcoQdZp","timestamp":"2026-02-11T15:46:54.959Z","uuid":"979047cc-2cbe-4b07-86af-2716909e504f"}
{"parentUuid":"dad85aab-3520-406d-9612-e79ea49f756b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014q2g8JCRfuAmumLCcoQdZp","type":"tool_result","content":"specs/09-IMPLEMENTATION-ROADMAP.md:1319:| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\nspecs/codex-spec-check-04.md:42:### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)\nspecs/codex-spec-check-04.md:49:- Schema still allows `payment_pending` (and not `payment_selected`):\nspecs/codex-spec-check-04.md:51:- Business logic still models `payment_pending` transitions:\nspecs/codex-spec-check-04.md:54:- Roadmap still models/testing `payment_pending`:\nspecs/codex-spec-check-04.md:95:- `pay` endpoint error text still references `payment_pending`:\nspecs/codex-spec-check-04.md:178:1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.\nspecs/05-BUSINESS-LOGIC.md:240:| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\nspecs/05-BUSINESS-LOGIC.md:432:    note right of payment_pending : Inventory reserved\nspecs/05-BUSINESS-LOGIC.md:585:1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\nspecs/05-BUSINESS-LOGIC.md:1062:See Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\nspecs/05-BUSINESS-LOGIC.md:1681:| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |"}]},"uuid":"1010e7b6-cd80-4cd9-ad87-f9d9f40f3a76","timestamp":"2026-02-11T15:46:54.959Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"specs/09-IMPLEMENTATION-ROADMAP.md:1319:| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\nspecs/codex-spec-check-04.md:42:### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)\nspecs/codex-spec-check-04.md:49:- Schema still allows `payment_pending` (and not `payment_selected`):\nspecs/codex-spec-check-04.md:51:- Business logic still models `payment_pending` transitions:\nspecs/codex-spec-check-04.md:54:- Roadmap still models/testing `payment_pending`:\nspecs/codex-spec-check-04.md:95:- `pay` endpoint error text still references `payment_pending`:\nspecs/codex-spec-check-04.md:178:1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.\nspecs/05-BUSINESS-LOGIC.md:240:| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\nspecs/05-BUSINESS-LOGIC.md:432:    note right of payment_pending : Inventory reserved\nspecs/05-BUSINESS-LOGIC.md:585:1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\nspecs/05-BUSINESS-LOGIC.md:1062:See Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\nspecs/05-BUSINESS-LOGIC.md:1681:| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |","numLines":12},"sourceToolAssistantUUID":"dad85aab-3520-406d-9612-e79ea49f756b"}
{"parentUuid":"1010e7b6-cd80-4cd9-ad87-f9d9f40f3a76","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_015xZRoRsfEcJRMDhdsV1uoc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NKpmRkTdmaZUh3tVJp3m97","name":"Grep","input":{"pattern":"requires_action|authorized","path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md","output_mode":"content"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":592,"cache_read_input_tokens":67482,"output_tokens":235,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":592,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2YRUxcqjhyrTVsxpHP1","type":"assistant","uuid":"9393ce5a-c055-49a2-a47a-19821f33aef8","timestamp":"2026-02-11T15:46:55.667Z"}
{"parentUuid":"9393ce5a-c055-49a2-a47a-19821f33aef8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_01NKpmRkTdmaZUh3tVJp3m97","toolUseID":"toolu_01NKpmRkTdmaZUh3tVJp3m97","timestamp":"2026-02-11T15:46:55.954Z","uuid":"13f553eb-f695-43e8-9eea-4a65f7fff8bd"}
{"parentUuid":"9393ce5a-c055-49a2-a47a-19821f33aef8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NKpmRkTdmaZUh3tVJp3m97","type":"tool_result","content":"763:| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |"}]},"uuid":"4ebe6fec-f86d-4651-bfdd-215ea2f92bf8","timestamp":"2026-02-11T15:46:55.953Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"763:| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |","numLines":1},"sourceToolAssistantUUID":"9393ce5a-c055-49a2-a47a-19821f33aef8"}
{"parentUuid":"4ebe6fec-f86d-4651-bfdd-215ea2f92bf8","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GGaPm5QxXqGuYRWNrX2wk5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"There are remaining `payment_pending` references in:\n1. `specs/09-IMPLEMENTATION-ROADMAP.md:1319` - test case about expiring checkout\n2. `specs/05-BUSINESS-LOGIC.md:240` - inventory reserve description\n3. `specs/05-BUSINESS-LOGIC.md:432` - mermaid diagram note\n4. `specs/05-BUSINESS-LOGIC.md:585` - expiry description\n5. `specs/05-BUSINESS-LOGIC.md:1062` - cross-reference text\n6. `specs/05-BUSINESS-LOGIC.md:1681` - enum summary\n\n\n7. `specs/01-DATABASE-SCHEMA.md` has `authorized` for `financial_status`, which is correct since that enum does include `authorized` as a valid value.\n\nNow I need to go through and update all those remaining `payment_pending` references.","signature":"ErkGCkYICxgCKkATXZqfrIVyG3UShDCSWklCjAQ9wFrTp2niKHTDzsRosHZWhHySnJ/B79V1X5gBEQQveUaWLxM0Xlxat5xavlpfEgx3+kAbHWp2A7tg+wQaDFnNwc//k/o3GTUEKCIw+vBKaFFW9W4TfQWLmLyLJLumnoMsPSCL13r4EbPRNkKrd1TPv2yh5iYYUl269oBfKqAFPm3XKad/N/GpyIlOHQ3yqqI0fQ7k8DVyoYZiLYRHt3QJAOilzKkAyzMmzRjkP3+FhktQQdFI/0GBIHR2C7PJqybm59Fk+pGQcBrsAJ36Fobo3SAV0j2GyksCgqygLtY5xUPmVaxa6ABE4diR2GvI1DBUX4XLFcGH8rz997sVZNHzpcCF26oczATwIUQGBwCd1Ehrnv0bzgahTSD81jvLEP49O1W10MRjEAdsj5lqydHDiy+5QzNE3ZkfTeHMJ+jbII7WwMIrWBeaN8g+7dqupgWVfxJ/9olaFPTVdwk40YhEBmSOi8j+TbiDHdKzkHpOT8vMraf9R7X5Zo7O3WI6i0sVkcWvMq3PeY7beTgo8UZZ2QQte/uu+0tCAoWsbH600c+hpVGUfGuIgiAUAt4w4PAEDybI5NIBLeTS8uAgpMiG0ABUVpwbodlt2ng6tfl/L7XI9MWYiZa2zAmRNddExiTAUmNZOvjH/sOyNABzaNktDHKFYelqjKXB2DOnF6+CeNnkQg44WpNNJ9zHZivLqHe/vzVbgNLCmuq17bdcSNT/GQmbGnKZSv0cb1Sf7y51TIR+cwqWHDoVwe13YYBFzWVVA0L30M+oI4S3ceXrbejI8QKcWm2sthA7Fwi+q5nG6SJDWQFB5/oBOqEcj8Utd+0CtGa7ZcpdLC9JEM+1P0R24PFnX5DqfKYSrOmylik0O6iE5No1Q49LUo7nsPdJttPe3MSB4GLB145ZTgEgHmfqGYfaKTG3ofiJo70hovgnEoc1OoSQ3f6TNuhtqU98uua+390J0+e7xnwCfNpaUysq9aEOHoAuqtX8cQ/bY4ebjWGp0FYUcjtx8Bd489StpZ2HMHIwhLPIIHEs9dwD2E56lSbP3YkIXzlyHmL3MiOxGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":969,"cache_read_input_tokens":68074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":969},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRrSCcaTMGBPT4y8F9","type":"assistant","uuid":"d22febdf-136f-4be2-96a3-8f4ae081e9e0","timestamp":"2026-02-11T15:47:02.727Z"}
{"parentUuid":"d22febdf-136f-4be2-96a3-8f4ae081e9e0","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GGaPm5QxXqGuYRWNrX2wk5","type":"message","role":"assistant","content":[{"type":"text","text":"There are still `payment_pending` references in specs 05 and 09. The `authorized` match is in `financial_status` (which is correct - that's a different column). Let me fix the remaining `payment_pending` references."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":969,"cache_read_input_tokens":68074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":969},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRrSCcaTMGBPT4y8F9","type":"assistant","uuid":"f7e8b934-3e2e-43a7-8f8a-97703a028f6d","timestamp":"2026-02-11T15:47:03.251Z"}
{"parentUuid":"f7e8b934-3e2e-43a7-8f8a-97703a028f6d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GGaPm5QxXqGuYRWNrX2wk5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01K6z9v7FSnCmPh3tiUKgemH","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":235,"limit":10}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":969,"cache_read_input_tokens":68074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":969},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRrSCcaTMGBPT4y8F9","type":"assistant","uuid":"dda080d4-1e56-41cc-b132-1dc051c2b1f3","timestamp":"2026-02-11T15:47:03.934Z"}
{"parentUuid":"dda080d4-1e56-41cc-b132-1dc051c2b1f3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01K6z9v7FSnCmPh3tiUKgemH","toolUseID":"toolu_01K6z9v7FSnCmPh3tiUKgemH","timestamp":"2026-02-11T15:47:03.940Z","uuid":"6452e409-5fd0-44bd-afd0-1c89a980142d"}
{"parentUuid":"dda080d4-1e56-41cc-b132-1dc051c2b1f3","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01K6z9v7FSnCmPh3tiUKgemH","type":"tool_result","content":"   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_pending` | 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→"}]},"uuid":"84d0d9be-b708-431e-8e25-470f1b04afb4","timestamp":"2026-02-11T15:47:03.939Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n","numLines":10,"startLine":235,"totalLines":1710}},"sourceToolAssistantUUID":"dda080d4-1e56-41cc-b132-1dc051c2b1f3"}
{"parentUuid":"84d0d9be-b708-431e-8e25-470f1b04afb4","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GGaPm5QxXqGuYRWNrX2wk5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013XbwzK19HFiVEiqiQkZRSa","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":425,"limit":15}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":969,"cache_read_input_tokens":68074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":969},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRrSCcaTMGBPT4y8F9","type":"assistant","uuid":"8739cf55-90fa-4a81-974e-9063c8b15e3b","timestamp":"2026-02-11T15:47:04.749Z"}
{"parentUuid":"8739cf55-90fa-4a81-974e-9063c8b15e3b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_013XbwzK19HFiVEiqiQkZRSa","toolUseID":"toolu_013XbwzK19HFiVEiqiQkZRSa","timestamp":"2026-02-11T15:47:04.751Z","uuid":"3ac714b8-379c-46db-9faa-09d6aae19cb3"}
{"parentUuid":"8739cf55-90fa-4a81-974e-9063c8b15e3b","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013XbwzK19HFiVEiqiQkZRSa","type":"tool_result","content":"   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_pending : 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`"}]},"uuid":"f2858817-1c56-4096-90a4-0c1710fef94c","timestamp":"2026-02-11T15:47:04.751Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`","numLines":15,"startLine":425,"totalLines":1710}},"sourceToolAssistantUUID":"8739cf55-90fa-4a81-974e-9063c8b15e3b"}
{"parentUuid":"f2858817-1c56-4096-90a4-0c1710fef94c","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GGaPm5QxXqGuYRWNrX2wk5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NnQz6E1USrBz2Qt7SQDJv2","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":580,"limit":10}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":969,"cache_read_input_tokens":68074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":969},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRrSCcaTMGBPT4y8F9","type":"assistant","uuid":"141db3e0-5ff8-4030-8eb5-cc1107b7a6dd","timestamp":"2026-02-11T15:47:05.407Z"}
{"parentUuid":"141db3e0-5ff8-4030-8eb5-cc1107b7a6dd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01NnQz6E1USrBz2Qt7SQDJv2","toolUseID":"toolu_01NnQz6E1USrBz2Qt7SQDJv2","timestamp":"2026-02-11T15:47:05.411Z","uuid":"136df21c-8878-4947-ac33-4aaf9bf2fa9d"}
{"parentUuid":"141db3e0-5ff8-4030-8eb5-cc1107b7a6dd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NnQz6E1USrBz2Qt7SQDJv2","type":"tool_result","content":"   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_pending`): 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"}]},"uuid":"fc45a0d4-a608-402c-9878-5a333172eecd","timestamp":"2026-02-11T15:47:05.411Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events","numLines":10,"startLine":580,"totalLines":1710}},"sourceToolAssistantUUID":"141db3e0-5ff8-4030-8eb5-cc1107b7a6dd"}
{"parentUuid":"fc45a0d4-a608-402c-9878-5a333172eecd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GGaPm5QxXqGuYRWNrX2wk5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SUsn7DfLqzsommRtcxiRgn","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":1057,"limit":10}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":969,"cache_read_input_tokens":68074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":969},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRrSCcaTMGBPT4y8F9","type":"assistant","uuid":"9ed48607-7bb3-443a-8194-38e4d3fe5557","timestamp":"2026-02-11T15:47:06.087Z"}
{"parentUuid":"9ed48607-7bb3-443a-8194-38e4d3fe5557","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01SUsn7DfLqzsommRtcxiRgn","toolUseID":"toolu_01SUsn7DfLqzsommRtcxiRgn","timestamp":"2026-02-11T15:47:06.090Z","uuid":"3a2eccf0-6fee-48d8-b913-490eb951c727"}
{"parentUuid":"9ed48607-7bb3-443a-8194-38e4d3fe5557","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SUsn7DfLqzsommRtcxiRgn","type":"tool_result","content":"  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_pending -> 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."}]},"uuid":"7cb80cd9-2b1d-4681-a609-0e802524da61","timestamp":"2026-02-11T15:47:06.090Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.","numLines":10,"startLine":1057,"totalLines":1710}},"sourceToolAssistantUUID":"9ed48607-7bb3-443a-8194-38e4d3fe5557"}
{"parentUuid":"7cb80cd9-2b1d-4681-a609-0e802524da61","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GGaPm5QxXqGuYRWNrX2wk5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MybbkNb64GocFdUB3NYkPe","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","offset":1676,"limit":10}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":969,"cache_read_input_tokens":68074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":969},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRrSCcaTMGBPT4y8F9","type":"assistant","uuid":"00b9692c-9fb1-4e5a-85fd-35e688baf6ab","timestamp":"2026-02-11T15:47:07.071Z"}
{"parentUuid":"00b9692c-9fb1-4e5a-85fd-35e688baf6ab","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01MybbkNb64GocFdUB3NYkPe","toolUseID":"toolu_01MybbkNb64GocFdUB3NYkPe","timestamp":"2026-02-11T15:47:07.074Z","uuid":"224de33e-9893-4871-9519-79316529df98"}
{"parentUuid":"00b9692c-9fb1-4e5a-85fd-35e688baf6ab","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MybbkNb64GocFdUB3NYkPe","type":"tool_result","content":"  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`), PaymentPending (`payment_pending`), 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`) |"}]},"uuid":"4762bb75-3413-4723-ba90-13482149de3e","timestamp":"2026-02-11T15:47:07.073Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","content":"| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |","numLines":10,"startLine":1676,"totalLines":1710}},"sourceToolAssistantUUID":"00b9692c-9fb1-4e5a-85fd-35e688baf6ab"}
{"parentUuid":"4762bb75-3413-4723-ba90-13482149de3e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01GGaPm5QxXqGuYRWNrX2wk5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013Y7B4annQRAXPk47WRbF6W","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","offset":1314,"limit":10}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":969,"cache_read_input_tokens":68074,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":969},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YRrSCcaTMGBPT4y8F9","type":"assistant","uuid":"8a34d9dc-eb84-4b73-806b-8beed492a49d","timestamp":"2026-02-11T15:47:07.601Z"}
{"parentUuid":"8a34d9dc-eb84-4b73-806b-8beed492a49d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_013Y7B4annQRAXPk47WRbF6W","toolUseID":"toolu_013Y7B4annQRAXPk47WRbF6W","timestamp":"2026-02-11T15:47:07.604Z","uuid":"c2316d01-09fa-43d1-9fec-bb4a6cb26f21"}
{"parentUuid":"8a34d9dc-eb84-4b73-806b-8beed492a49d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013Y7B4annQRAXPk47WRbF6W","type":"tool_result","content":"  1314→| Test Name | Setup / Preconditions | Key Assertions | Category |\n  1315→|-----------|----------------------|----------------|----------|\n  1316→| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n  1317→| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n  1318→| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n  1319→| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n  1320→| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n  1321→\n  1322→---\n  1323→"}]},"uuid":"e1d41ac9-31f1-4510-a80d-03e5298f87f2","timestamp":"2026-02-11T15:47:07.604Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","content":"| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n","numLines":10,"startLine":1314,"totalLines":2066}},"sourceToolAssistantUUID":"8a34d9dc-eb84-4b73-806b-8beed492a49d"}
{"parentUuid":"e1d41ac9-31f1-4510-a80d-03e5298f87f2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01E18YQPTGQFXkSczuC9sHCG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011gJKZFaxN4sfN3R2cun5SC","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |","new_string":"| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2305,"cache_read_input_tokens":69043,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2305},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YSj7SaBJky3Yk1291M","type":"assistant","uuid":"056c5de9-8125-4b24-8514-a5ea3ba6a940","timestamp":"2026-02-11T15:47:11.655Z"}
{"parentUuid":"056c5de9-8125-4b24-8514-a5ea3ba6a940","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_011gJKZFaxN4sfN3R2cun5SC","toolUseID":"toolu_011gJKZFaxN4sfN3R2cun5SC","timestamp":"2026-02-11T15:47:11.661Z","uuid":"fd9a1863-9098-4dbd-a4dc-5992ca65bc7f"}
{"parentUuid":"056c5de9-8125-4b24-8514-a5ea3ba6a940","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011gJKZFaxN4sfN3R2cun5SC","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"d4b15c57-edb7-4775-8a48-a871c25ed734","timestamp":"2026-02-11T15:47:11.660Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |","newString":"| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_selected : Payment method selected\n    payment_selected --> completed : Payment confirmed\n    payment_selected --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_selected\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_selected`.\n\n#### Transition: payment_selected -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":237,"oldLines":7,"newStart":237,"newLines":7,"lines":[" "," | Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |"," |---|---|---|---|---|","-| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |","+| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |"," | **Release** | Checkout expires or is abandoned | No change | -quantity | None |"," | **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |"," | **Restock** | Refund processed with restock flag | +quantity | No change | None |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"056c5de9-8125-4b24-8514-a5ea3ba6a940"}
{"parentUuid":"d4b15c57-edb7-4775-8a48-a871c25ed734","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01E18YQPTGQFXkSczuC9sHCG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EVmmpSCDhM8syGZsUhkg58","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"    note right of payment_pending : Inventory reserved","new_string":"    note right of payment_selected : Inventory reserved"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2305,"cache_read_input_tokens":69043,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2305},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YSj7SaBJky3Yk1291M","type":"assistant","uuid":"97a9d0f8-101d-4a61-99f5-7abbef5f01f6","timestamp":"2026-02-11T15:47:12.692Z"}
{"parentUuid":"97a9d0f8-101d-4a61-99f5-7abbef5f01f6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01EVmmpSCDhM8syGZsUhkg58","toolUseID":"toolu_01EVmmpSCDhM8syGZsUhkg58","timestamp":"2026-02-11T15:47:12.696Z","uuid":"c9a6dc5c-a748-43d8-bc78-0e629edc95fe"}
{"parentUuid":"97a9d0f8-101d-4a61-99f5-7abbef5f01f6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EVmmpSCDhM8syGZsUhkg58","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"a8a2d9fa-7b68-4dd6-aaa7-e3c3aab6556a","timestamp":"2026-02-11T15:47:12.695Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"    note right of payment_pending : Inventory reserved","newString":"    note right of payment_selected : Inventory reserved","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_selected : Payment method selected\n    payment_selected --> completed : Payment confirmed\n    payment_selected --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_pending : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_selected\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_selected`.\n\n#### Transition: payment_selected -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":429,"oldLines":7,"newStart":429,"newLines":7,"lines":[" ","     note right of addressed : Triggers pricing recalculation","     note right of shipping_selected : Triggers pricing recalculation","-    note right of payment_pending : Inventory reserved","+    note right of payment_selected : Inventory reserved","     note right of completed : Order created, inventory committed","     note right of expired : Reserved inventory released"," ```"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"97a9d0f8-101d-4a61-99f5-7abbef5f01f6"}
{"parentUuid":"a8a2d9fa-7b68-4dd6-aaa7-e3c3aab6556a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01E18YQPTGQFXkSczuC9sHCG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LsLCp2EeJfe8JBmH13qLBB","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.","new_string":"1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2305,"cache_read_input_tokens":69043,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2305},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YSj7SaBJky3Yk1291M","type":"assistant","uuid":"aaf31507-6edf-4768-95e8-7c0cc583e6cc","timestamp":"2026-02-11T15:47:13.912Z"}
{"parentUuid":"aaf31507-6edf-4768-95e8-7c0cc583e6cc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01LsLCp2EeJfe8JBmH13qLBB","toolUseID":"toolu_01LsLCp2EeJfe8JBmH13qLBB","timestamp":"2026-02-11T15:47:13.917Z","uuid":"a164ed4b-0b00-4555-91c9-f4ef159ff350"}
{"parentUuid":"aaf31507-6edf-4768-95e8-7c0cc583e6cc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LsLCp2EeJfe8JBmH13qLBB","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"13becd87-bf51-4aae-95fb-18d374cebda2","timestamp":"2026-02-11T15:47:13.917Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.","newString":"1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release.","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_selected : Payment method selected\n    payment_selected --> completed : Payment confirmed\n    payment_selected --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_selected : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_selected\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_selected`.\n\n#### Transition: payment_selected -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":582,"oldLines":7,"newStart":582,"newLines":7,"lines":[" **Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours."," "," **Actions:**","-1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.","+1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release."," 2. Transition status to `expired`."," 3. Dispatch `CheckoutExpired` event."," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"aaf31507-6edf-4768-95e8-7c0cc583e6cc"}
{"parentUuid":"13becd87-bf51-4aae-95fb-18d374cebda2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01E18YQPTGQFXkSczuC9sHCG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DxduwhoNZed7yAmVv3vinc","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"See Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure.","new_string":"See Section 6.2 \"Transition: payment_selected -> completed\" for the full procedure."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2305,"cache_read_input_tokens":69043,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2305},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YSj7SaBJky3Yk1291M","type":"assistant","uuid":"4f7fad90-4c93-4773-a2f2-cfaf6026deac","timestamp":"2026-02-11T15:47:15.090Z"}
{"parentUuid":"4f7fad90-4c93-4773-a2f2-cfaf6026deac","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01DxduwhoNZed7yAmVv3vinc","toolUseID":"toolu_01DxduwhoNZed7yAmVv3vinc","timestamp":"2026-02-11T15:47:15.094Z","uuid":"0fa47ccc-bd9c-4d2f-ae41-3374ecc22923"}
{"parentUuid":"4f7fad90-4c93-4773-a2f2-cfaf6026deac","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DxduwhoNZed7yAmVv3vinc","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"f8bac8af-d113-4aee-88d2-af97eb55d3f6","timestamp":"2026-02-11T15:47:15.094Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"See Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure.","newString":"See Section 6.2 \"Transition: payment_selected -> completed\" for the full procedure.","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_selected : Payment method selected\n    payment_selected --> completed : Payment confirmed\n    payment_selected --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_selected : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_selected\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_selected`.\n\n#### Transition: payment_selected -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1059,"oldLines":7,"newStart":1059,"newLines":7,"lines":[" "," ### 11.1 Order Creation (from Completed Checkout)"," ","-See Section 6.2 \"Transition: payment_pending -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.","+See Section 6.2 \"Transition: payment_selected -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction."," "," **Key requirements for the order record:**"," - Snapshot all checkout data (addresses, email, totals) at time of order creation."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"4f7fad90-4c93-4773-a2f2-cfaf6026deac"}
{"parentUuid":"f8bac8af-d113-4aee-88d2-af97eb55d3f6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01E18YQPTGQFXkSczuC9sHCG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018w21ok4gBAhHW3n9qBkAkp","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","old_string":"| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |","new_string":"| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2305,"cache_read_input_tokens":69043,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2305},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YSj7SaBJky3Yk1291M","type":"assistant","uuid":"05ff9f8c-bd3c-4ea7-91f8-8707284c6925","timestamp":"2026-02-11T15:47:16.662Z"}
{"parentUuid":"05ff9f8c-bd3c-4ea7-91f8-8707284c6925","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_018w21ok4gBAhHW3n9qBkAkp","toolUseID":"toolu_018w21ok4gBAhHW3n9qBkAkp","timestamp":"2026-02-11T15:47:16.672Z","uuid":"2c9dc412-8835-46a6-a6c1-ed254397a584"}
{"parentUuid":"05ff9f8c-bd3c-4ea7-91f8-8707284c6925","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018w21ok4gBAhHW3n9qBkAkp","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md has been updated successfully."}]},"uuid":"e7ade8aa-2ef5-4ee6-a528-0d8116133c3e","timestamp":"2026-02-11T15:47:16.671Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md","oldString":"| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |","newString":"| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |","originalFile":"# 05 - Business Logic Specification\n\n> Shopify-clone e-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n\n---\n\n## Conventions\n\n| Convention | Detail |\n|---|---|\n| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n| Rounding | Round each line independently, then sum (prevents rounding drift) |\n| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n\n---\n\n## 1. Multi-Tenancy and Store Isolation\n\n### 1.1 Tenant Resolution Middleware\n\n**Class:** `App\\Http\\Middleware\\ResolveStore`\n\n**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n\n#### Storefront Route Resolution (from request hostname)\n\n1. Extract hostname from the incoming HTTP request.\n2. Look up `store_domains` table where `hostname` matches.\n3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n5. If hostname is not found in `store_domains`: return HTTP 404.\n6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n\n#### Admin Route Resolution (from session)\n\n1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n2. Verify the authenticated user has a `store_users` record for that store.\n3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n\n```mermaid\nflowchart TD\n    A[Incoming Request] --> B{Route type?}\n    B -->|Storefront| C[Extract hostname]\n    C --> D{Hostname in cache?}\n    D -->|Yes| E[Load store from cache]\n    D -->|No| F[Query store_domains table]\n    F --> G{Found?}\n    G -->|No| H[Return 404]\n    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    I --> E\n    E --> J{Store suspended?}\n    J -->|Yes| K[Return 503 maintenance]\n    J -->|No| L[Bind store as singleton, continue]\n\n    B -->|Admin| M[Read store_id from session]\n    M --> N{User has store_users record?}\n    N -->|No| O[Return 403]\n    N -->|Yes| P{Store suspended?}\n    P -->|Yes| Q[Return 403 for mutations]\n    P -->|No| L\n```\n\n### 1.2 Store Scoping\n\n**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n\nThe 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\n**Behavior:**\n\n- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n\n**Models that must use this trait:**\nproducts, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n\n### 1.3 Role-Based Access Control\n\n**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|---|---|---|---|---|\n| Manage store settings | Yes | Yes | No | No |\n| Manage staff | Yes | Yes | No | No |\n| Manage products | Yes | Yes | Yes | No |\n| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n| Manage discounts | Yes | Yes | Yes | No |\n| Manage themes | Yes | Yes | No | No |\n| Manage apps | Yes | Yes | No | No |\n| View analytics | Yes | Yes | Yes | No |\n| Manage shipping/tax | Yes | Yes | No | No |\n| Process refunds | Yes | Yes | No | No |\n| Create fulfillments | Yes | Yes | Yes | No |\n\n**Implementation requirements:**\n\n- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n- Register gate definitions in a service provider.\n- 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- 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\n---\n\n## 2. Product Lifecycle\n\n### 2.1 Status Transitions\n\n**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n\n```mermaid\nstateDiagram-v2\n    [*] --> Draft : Product created\n    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n    Draft --> Archived : Archive\n    Active --> Archived : Archive (hide from storefront)\n    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n    Active --> Draft : Revert (BLOCKED if order_lines exist)\n    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n```\n\n**Transition rules:**\n\n| From | To | Preconditions |\n|---|---|---|\n| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n| Draft | Archived | None |\n| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n| Archived | Active | Same preconditions as Draft to Active |\n| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n\n**Deletion rules:**\n- Only `draft` products with zero `order_lines` references may be hard-deleted.\n- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n\n**Service:** `App\\Services\\ProductService`\n- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n- On activation: set `published_at` to current time if not already set.\n- Dispatch `ProductStatusChanged` event after successful transition.\n\n### 2.2 Variant and Option Matrix\n\n**Rules:**\n\n- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n- Each option has 1 or more values, stored in `product_option_values`.\n- Variants represent the cartesian product of option values (or a manually curated subset).\n- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n\n**Matrix rebuild logic (when options change):**\n\n```\nPROCEDURE rebuildMatrix(product):\n    current_option_values = load all option values grouped by option\n    desired_combos = cartesianProduct(current_option_values)\n    existing_variants = load all variants with their option value sets\n\n    FOR EACH combo IN desired_combos:\n        IF combo matches an existing variant's option value set:\n            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n        ELSE:\n            CREATE new variant with default pricing from first existing variant\n\n    FOR EACH existing_variant NOT matching any desired combo:\n        IF variant has order_line references:\n            ARCHIVE variant (set status = 'archived')\n        ELSE:\n            DELETE variant\n```\n\n**Service:** `App\\Services\\VariantMatrixService`\n\n### 2.3 SKU Uniqueness\n\n- Combination of store_id (derived through the product's store) and SKU must be unique.\n- Enforced at application level before save.\n- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n\n### 2.4 Handle/Slug Generation\n\n**Helper class:** `App\\Support\\HandleGenerator`\n\n- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n- Merchants can manually edit the handle; on save, re-validate uniqueness.\n- Same logic applies to collections and pages.\n\n---\n\n## 3. Inventory Management\n\n### 3.1 Tracking Model\n\nEach `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n\n| Field | Description |\n|---|---|\n| `quantity_on_hand` | Total physical stock count |\n| `quantity_reserved` | Units held for active checkouts or pending orders |\n| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n\n### 3.2 Inventory Policy\n\nPer-variant policy stored in `inventory_items.policy`:\n\n| Policy | Behavior |\n|---|---|\n| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n\n### 3.3 Inventory Operations\n\n**Service:** `App\\Services\\InventoryService`\n\nAll operations must execute within a database transaction.\n\n```mermaid\nflowchart LR\n    subgraph \"Stock Lifecycle\"\n        A[On Hand] -->|Reserve| B[Reserved]\n        B -->|Commit on payment| C[Deducted]\n        B -->|Release on expiry| A\n        C -->|Restock on refund| A\n    end\n```\n\n**Operation definitions:**\n\n| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n|---|---|---|---|---|\n| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n\n### 3.4 Concurrency\n\nSQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n\n---\n\n## 4. Cart Service\n\n### 4.1 Cart Lifecycle\n\n**Service:** `App\\Services\\CartService`\n\n```mermaid\nstateDiagram-v2\n    [*] --> active : Cart created\n    active --> converted : Checkout completed successfully\n    active --> abandoned : Cleanup job (14 days inactive)\n```\n\n**Cart identification:**\n- Guest users: cart ID stored in session cookie.\n- Authenticated customers: cart linked via `customer_id` foreign key.\n\nThe 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\n**Cart merge on login:**\n\n```\nPROCEDURE mergeGuestCart(guestCart, customerCart):\n    FOR EACH line IN guestCart.lines:\n        existingLine = customerCart.findLineByVariant(line.variant_id)\n        IF existingLine EXISTS:\n            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n            RECALCULATE existingLine amounts\n        ELSE:\n            MOVE line to customerCart\n    MARK guestCart as abandoned\n    RECALCULATE all line amounts on customerCart\n```\n\n### 4.2 Cart Operations\n\n**Create cart:**\n- Accepts: store, optional customer\n- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n\n**Add line - validations (in order):**\n\n1. Variant exists and belongs to the current store (through its product).\n2. Product has status = `active`.\n3. Variant has status = `active`.\n4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n6. Set `unit_price_amount` from the variant's `price_amount`.\n7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n10. Increment `cart_version`.\n\n**Update quantity:**\n- Validate new quantity > 0. If 0, remove the line instead.\n- Re-check inventory policy for the new quantity.\n- Recalculate line amounts.\n- Increment `cart_version`.\n\n**Remove line:**\n- Delete the `cart_lines` record.\n- Increment `cart_version`.\n\n### 4.3 Cart Version (Optimistic Concurrency)\n\nEvery cart mutation increments `cart_version` by 1.\n\n**For API clients:**\n- Client sends `expected_version` with each mutation request.\n- 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- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n\n**For Livewire/session-based flows:**\n- Still implemented for consistency, but less critical since requests are serialized per session.\n\n### 4.4 Line Amount Calculation\n\n```\nline_subtotal_amount = unit_price_amount * quantity\nline_discount_amount = 0  (updated by discount engine during checkout)\nline_total_amount    = line_subtotal_amount - line_discount_amount\n```\n\nAll values are integers in minor currency units.\n\n### 4.5 Cart Expiration\n\n**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n\n- Schedule: daily\n- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n- Marks them as `abandoned`.\n- Releases any reserved inventory associated with active checkouts for these carts.\n\n---\n\n## 5. Pricing Engine\n\n### 5.1 Calculation Order (deterministic pipeline)\n\n**Service:** `App\\Services\\PricingEngine`\n\nThe pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n\n```mermaid\nflowchart TD\n    A[\"Step 1: Line Subtotals\n    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n    SUM of all line subtotals\"]\n    B --> C[\"Step 3: Discount\n    Apply discount rules (Section 7)\n    Allocate proportionally across qualifying lines\"]\n    C --> D[\"Step 4: Discounted Subtotal\n    subtotal - total_discount\"]\n    D --> E[\"Step 5: Shipping\n    Based on selected method and rate rules (Section 9)\"]\n    E --> F[\"Step 6: Tax\n    Calculated on discounted line amounts\n    + shipping if taxable (Section 8)\"]\n    F --> G[\"Step 7: Total\n    discounted_subtotal + shipping + tax_total\"]\n```\n\n### 5.2 Output Structure\n\n**Value Object:** `App\\ValueObjects\\PricingResult`\n\n| Field | Type | Description |\n|---|---|---|\n| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n| `discount` | integer (cents) | Total discount amount |\n| `shipping` | integer (cents) | Shipping cost |\n| `taxLines` | array of TaxLine | Individual tax line items |\n| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n| `currency` | string | ISO 4217 currency code |\n\n**Value Object:** `App\\ValueObjects\\TaxLine`\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | string | Tax label (e.g., \"State Tax\") |\n| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n| `amount` | integer (cents) | Calculated tax amount |\n\n### 5.3 Rounding Strategy\n\n- Each line's discount allocation, tax, and total are rounded independently.\n- Totals are computed by summing already-rounded line amounts.\n- This prevents accumulation of fractional cents across lines.\n- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n\n### 5.4 Determinism and Snapshotting\n\n- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n- The snapshot structure mirrors the PricingResult value object.\n\n---\n\n## 6. Checkout State Machine\n\n### 6.1 States\n\n**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`\n\n```mermaid\nstateDiagram-v2\n    [*] --> started : Checkout created from cart\n    started --> addressed : Address provided\n    addressed --> shipping_selected : Shipping method chosen\n    shipping_selected --> payment_selected : Payment method selected\n    payment_selected --> completed : Payment confirmed\n    payment_selected --> expired : Timeout (24h)\n\n    started --> expired : Timeout (24h)\n    addressed --> expired : Timeout (24h)\n    shipping_selected --> expired : Timeout (24h)\n\n    note right of addressed : Triggers pricing recalculation\n    note right of shipping_selected : Triggers pricing recalculation\n    note right of payment_selected : Inventory reserved\n    note right of completed : Order created, inventory committed\n    note right of expired : Reserved inventory released\n```\n\n### 6.2 Transitions and Validation\n\n**Service:** `App\\Services\\CheckoutService`\n\n#### Transition: started -> addressed\n\n**Method:** `setAddress(checkout, addressData)`\n\n**Required data:**\n- `email` - valid email address\n- `shipping_address` with required fields:\n\n| Field | Required | Type | Notes |\n|---|---|---|---|\n| `first_name` | Yes | string | |\n| `last_name` | Yes | string | |\n| `address1` | Yes | string | |\n| `address2` | No | string | |\n| `company` | No | string | |\n| `city` | Yes | string | |\n| `province` | No | string | |\n| `province_code` | No | string | Used for zone matching |\n| `country` | Yes | string | 2-letter ISO 3166-1 code |\n| `postal_code` | Yes | string | |\n| `phone` | No | string | |\n\n**Actions:**\n1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n2. Save email and shipping_address_json on the checkout.\n3. Copy shipping address to billing_address_json by default (can be overridden later).\n4. Recalculate pricing (shipping zones and tax regions may change based on address).\n5. Transition status to `addressed`.\n6. Dispatch `CheckoutAddressed` event.\n\n#### Transition: addressed -> shipping_selected\n\n**Method:** `setShippingMethod(checkout, shippingRateId)`\n\n**Requires:**\n- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n- **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\n**Actions:**\n1. Resolve available shipping zones for the address (see Section 9).\n2. Verify the selected rate belongs to an applicable zone.\n3. Save shipping_method_id on the checkout.\n4. Recalculate pricing with the selected shipping rate.\n5. Transition status to `shipping_selected`.\n6. Dispatch `CheckoutShippingSelected` event.\n\n#### Transition: shipping_selected -> payment_selected\n\n**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n\n**Requires:**\n- All previous data is valid (address, shipping method).\n- Totals have been calculated and stored.\n- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n\n**Actions:**\n1. Store `payment_method` on the checkout record.\n2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n3. Set `expires_at` on the checkout to current time + 24 hours.\n4. Transition status to `payment_selected`.\n\n#### Transition: payment_selected -> completed\n\n**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n\n**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n\n**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\n**Actions (all within a single database transaction):**\n\n```\nPROCEDURE completeCheckout(checkout, paymentMethodData):\n    BEGIN TRANSACTION\n\n    1. IF order already exists for this checkout:\n           RETURN existing order (idempotent)\n\n    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n       IF result.success = false:\n           Release reserved inventory\n           THROW PaymentFailedException(result.errorCode)\n\n    3. Generate next sequential order number for this store\n\n    4. DETERMINE order statuses based on payment method:\n       IF checkout.payment_method IN ('credit_card', 'paypal'):\n           orderStatus = 'paid'\n           financialStatus = 'paid'\n           paymentStatus = 'captured'\n           inventoryAction = 'commit'\n       ELSE IF checkout.payment_method = 'bank_transfer':\n           orderStatus = 'pending'\n           financialStatus = 'pending'\n           paymentStatus = 'pending'\n           inventoryAction = 'keep_reserved'\n\n    5. CREATE order record with:\n       - All checkout data (addresses, email, totals snapshot)\n       - payment_method = checkout.payment_method\n       - status = orderStatus, financial_status = financialStatus\n       - fulfillment_status = 'unfulfilled'\n       - placed_at = now\n\n    6. FOR EACH cart line:\n       CREATE order_line with snapshot data:\n       - title_snapshot = product title + variant option labels\n       - sku_snapshot = variant SKU at time of purchase\n       - unit_price_amount, quantity, total_amount\n       - tax_lines_json, discount_allocations_json\n\n    7. CREATE payment record:\n       - Link to order, provider = 'mock', method = checkout.payment_method\n       - provider_payment_id = mock reference ID\n       - status = paymentStatus, amount = order total\n\n    8. IF inventoryAction = 'commit':\n       FOR EACH cart line:\n           CALL InventoryService.commit(variant, quantity)\n       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n\n    9. IF checkout has a discount code:\n       INCREMENT discount's usage_count by 1\n\n    10. SET cart status = 'converted'\n\n    11. SET checkout status = 'completed'\n\n    12. IF inventoryAction = 'commit' AND all order lines are digital:\n        Auto-create fulfillment (see Section 11.7)\n\n    13. DISPATCH OrderCreated event\n\n    COMMIT TRANSACTION\n    RETURN order\n```\n\n#### Transition: Any active state -> expired\n\n**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n\n**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n\n**Actions:**\n1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release.\n2. Transition status to `expired`.\n3. Dispatch `CheckoutExpired` event.\n\n### 6.3 Events\n\n| Event | Dispatched When |\n|---|---|\n| `CheckoutAddressed` | Address saved on checkout |\n| `CheckoutShippingSelected` | Shipping method selected |\n| `CheckoutCompleted` | Payment confirmed and order created |\n| `CheckoutExpired` | Checkout expired due to inactivity |\n\n---\n\n## 7. Discount Engine\n\n### 7.1 Discount Types\n\n**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n\n| Type | Behavior |\n|---|---|\n| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n\n**Stacking rules:**\n- Only one code discount per checkout.\n- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n\n### Discount Status Lifecycle\n\n| Status | Description | Transition |\n|--------|-------------|------------|\n| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n| `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| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n\n**Allowed transitions:**\n- `draft` -> `active`\n- `active` -> `disabled`\n- `active` -> `expired` (automatic)\n- `disabled` -> `active`\n\n### 7.2 Value Types\n\n**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n\n| Value Type | Calculation | Notes |\n|---|---|---|\n| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n\n### 7.3 Rules\n\nRules are stored in `discounts.rules_json` with the following structure:\n\n| Rule Key | Type | Description |\n|---|---|---|\n| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n\n**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\n### 7.4 Validation Flow\n\n**Service:** `App\\Services\\DiscountService`\n\n**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n\n```mermaid\nflowchart TD\n    A[Input: discount code + cart] --> B{Code exists in store?\n    Case-insensitive lookup}\n    B -->|No| C[\"Error: discount_not_found\"]\n    B -->|Yes| D{Status = active?}\n    D -->|No| E[\"Error: discount_expired\"]\n    D -->|Yes| F{starts_at <= now?}\n    F -->|No| G[\"Error: discount_not_yet_active\"]\n    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n    H -->|No| I[\"Error: discount_expired\"]\n    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n    N -->|Yes| O[\"Error: discount_not_applicable\"]\n    N -->|No| P[\"Valid - return discount\"]\n```\n\n### 7.5 Error Codes\n\n| Code | Description |\n|---|---|\n| `discount_not_found` | Code does not exist in this store |\n| `discount_expired` | Past `ends_at` date or status is not active |\n| `discount_not_yet_active` | Before `starts_at` date |\n| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n| `discount_not_applicable` | No qualifying products in cart |\n\n### 7.6 Discount Allocation\n\nFor `percent` and `fixed` discounts with applicable product restrictions:\n\n```\nPROCEDURE allocateDiscount(discount, cartLines):\n    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n                      OR product belongs to applicable_collection_ids\n    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n\n    IF discount.value_type = 'percent':\n        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n    ELSE IF discount.value_type = 'fixed':\n        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n\n    remainingDiscount = totalDiscount\n\n    FOR EACH qualifyingLine (except last):\n        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n        remainingDiscount -= lineDiscount\n        SET line.line_discount_amount = lineDiscount\n\n    // Last line gets the remainder (largest-remainder method)\n    SET lastLine.line_discount_amount = remainingDiscount\n\n    RECALCULATE line_total_amount for each affected line\n```\n\n**Storage of allocations:**\n- During checkout: update `cart_lines.line_discount_amount`.\n- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n\n### 7.7 Usage Tracking\n\nWhen an order is successfully created with a discount code:\n- Increment `discounts.usage_count` by 1.\n- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n\n---\n\n## 8. Tax Calculation\n\n### 8.1 Tax Provider Architecture\n\n**Interface:** `App\\Contracts\\TaxProvider`\n\n- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n\n**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n\n**Implementations:**\n- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n\n### 8.2 Manual Tax Mode\n\n**Rate resolution:**\n1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n\n**Calculation per line:**\n\n```\nline_tax = ROUND(line_total_after_discount * rate / 10000)\n```\n\nRound each line's tax independently, then sum for the total tax amount.\n\n### 8.3 Prices Include Tax\n\nControlled by `tax_settings.prices_include_tax` (boolean).\n\n#### Tax-inclusive pricing (prices_include_tax = true)\n\n- Displayed prices on the storefront are gross (include tax).\n- Subtotal shown to customer is gross.\n- Tax is \"extracted\" from the gross amount:\n\n```\nnet_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\ntax = gross_amount - net_amount\n```\n\n> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n\n**Example:** Price = 1190 cents, rate = 1900 (19%).\n- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n- tax = 1190 - 1000 = 190\n\n#### Tax-exclusive pricing (prices_include_tax = false)\n\n- Displayed prices are net (excluding tax).\n- Tax added on top:\n\n```\ntax = ROUND(net_amount * rate / 10000)\ntotal = net_amount + tax\n```\n\n**Example:** Price = 1000 cents, rate = 1900.\n- tax = ROUND(1000 * 1900 / 10000) = 190\n- total = 1190\n\n### 8.4 Stripe Tax Provider (Stub)\n\n**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n\n- Sends line items and shipping address to Stripe Tax API.\n- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n- Fallback behavior (configurable per store in `tax_settings.config_json`):\n\n| Fallback Setting | Behavior |\n|---|---|\n| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n\n### 8.5 Discount-Before-Tax Semantics\n\nDiscounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n\nFor 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\n**Calculation order within the pricing pipeline:**\n1. Calculate line subtotals.\n2. Apply discount allocations to lines.\n3. Calculate tax on discounted line amounts.\n4. Sum everything for the total.\n\n---\n\n## 9. Shipping Calculation\n\n### 9.1 Zone Matching\n\n**Service:** `App\\Services\\ShippingCalculator`\n\n**Zone matching algorithm:**\n\n```\nPROCEDURE getMatchingZone(address, store):\n    zones = load all shipping zones for store\n\n    bestMatch = null\n    bestSpecificity = -1\n\n    FOR EACH zone IN zones:\n        countryMatch = address.country_code IN zone.countries_json\n        regionMatch = address.province_code IN zone.regions_json\n\n        IF countryMatch AND regionMatch:\n            specificity = 2  // Most specific\n        ELSE IF countryMatch:\n            specificity = 1  // Country-only match\n        ELSE:\n            CONTINUE  // No match\n\n        IF specificity > bestSpecificity:\n            bestMatch = zone\n            bestSpecificity = specificity\n        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n            bestMatch = zone  // Tie-break: lowest ID wins\n\n    RETURN bestMatch  // null if no zone matches\n```\n\n**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n\n### 9.2 Rate Types\n\nFor the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n\n#### Flat rate\n\nConfig structure: `{\"amount\": 500}`\n\n- Cost = fixed amount regardless of cart contents.\n\n#### Weight-based rate\n\nConfig structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n\n```\nPROCEDURE calculateWeightRate(config, cart):\n    totalWeight = 0\n    FOR EACH line IN cart.lines:\n        IF line.variant.requires_shipping = true:\n            totalWeight += line.variant.weight_g * line.quantity\n\n    FOR EACH range IN config.ranges:\n        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n            RETURN range.amount\n\n    RETURN null  // Rate not available for this weight\n```\n\n#### Price-based rate\n\nConfig structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n\n```\nPROCEDURE calculatePriceRate(config, cartSubtotal):\n    FOR EACH range IN config.ranges:\n        IF range.min_amount <= cartSubtotal:\n            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n                RETURN range.amount\n\n    RETURN null  // Rate not available for this subtotal\n```\n\nA range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n\n#### Carrier-calculated rate (stub)\n\nConfig structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n\n- Placeholder for future carrier API integration.\n- Returns a hardcoded rate or error until implemented.\n\n### 9.3 Requires Shipping Check\n\n- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n- shipping_amount = 0 in the pricing result.\n- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n\n**Mixed carts** (some physical, some digital):\n- Shipping is calculated only for physical items' weight.\n- Digital items are excluded from weight calculations.\n\n### 9.4 Free Shipping Discount Override\n\nIf a validated discount has `value_type = 'free_shipping'`:\n- Override the calculated shipping amount to 0.\n- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n\n---\n\n## 10. Payment Processing (Mock PSP)\n\n### 10.1 Mock Payment Provider Architecture\n\nThe 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\n**Interface:** `App\\Contracts\\PaymentProvider`\n\nMust implement:\n- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n\n**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n\n**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n\n### 10.2 Supported Payment Methods\n\n**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n\n| Method | Behavior | Capture Timing |\n|---|---|---|\n| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n\n### 10.3 Magic Card Numbers (Credit Card Testing)\n\nWhen payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n\n| Card Number | Outcome | PaymentResult |\n|---|---|---|\n| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n| Any other valid-looking number | Success | `success = true`, status = `captured` |\n\nThe 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\n### 10.4 Mock Payment Flow\n\n```mermaid\nsequenceDiagram\n    participant Customer\n    participant Storefront\n    participant Backend\n    participant MockPSP\n\n    Customer->>Storefront: Select payment method + enter details\n    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n    Backend->>Backend: Reserve inventory (if not already reserved)\n    Backend->>MockPSP: charge(checkout, paymentMethodData)\n\n    alt Credit Card / PayPal (instant capture)\n        MockPSP-->>Backend: PaymentResult (success/failure)\n        alt Success\n            Backend->>Backend: Create order (financial_status = paid)\n            Backend->>Backend: Commit inventory, mark cart converted\n            Backend-->>Storefront: 200 OK (order confirmation)\n        else Failure (decline / insufficient funds)\n            Backend->>Backend: Release reserved inventory\n            Backend-->>Storefront: 422 (error code + message)\n        end\n    else Bank Transfer (deferred)\n        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n        Backend->>Backend: Create order (financial_status = pending)\n        Backend->>Backend: Inventory remains reserved (not committed)\n        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n    end\n```\n\n### 10.5 Payment State Machine\n\n**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n\n```mermaid\nstateDiagram-v2\n    [*] --> captured : Credit card / PayPal (success)\n    [*] --> failed : Credit card (decline)\n    [*] --> pending : Bank transfer (awaiting payment)\n    pending --> captured : Admin confirms payment received\n    pending --> failed : Auto-cancel job (unpaid timeout)\n    captured --> refunded : Full refund processed\n```\n\n### 10.6 Bank Transfer Instructions\n\nWhen `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n\n| Field | Value |\n|---|---|\n| Bank name | Mock Bank AG |\n| IBAN | DE89 3704 0044 0532 0130 00 |\n| BIC | COBADEFFXXX |\n| Reference | Order number (e.g., #1001) |\n| Amount | Order total formatted in EUR |\n\nThese are static mock values. No real bank interaction occurs.\n\n### 10.7 Admin: Confirm Payment (Bank Transfer)\n\nAdmin 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\n**Action flow:**\n1. Admin clicks \"Confirm Payment\" button.\n2. Backend updates `payments.status` from `pending` to `captured`.\n3. Backend updates `orders.financial_status` from `pending` to `paid`.\n4. Backend updates `orders.status` from `pending` to `paid`.\n5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n7. Dispatch `OrderPaid` event.\n\n### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n\n**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n\n- Schedule: daily\n- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n\n**Cancel action:**\n1. Release reserved inventory via `InventoryService.release()`.\n2. Update `orders.financial_status` to `voided`.\n3. Update `orders.status` to `cancelled`.\n4. Update `payments.status` to `failed`.\n5. Dispatch `OrderCancelled` event.\n\n---\n\n## 11. Order Management\n\n### 11.1 Order Creation (from Completed Checkout)\n\nSee Section 6.2 \"Transition: payment_selected -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n\n**Key requirements for the order record:**\n- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n- The mock payment response is encrypted before storage.\n- The entire operation must be atomic (single database transaction).\n- The `payment_method` from the checkout is copied to the order record.\n\n**Payment-method-specific order creation:**\n\n| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n|---|---|---|---|---|\n| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n\n### 11.2 Order Numbering\n\n- Sequential per store, starting at 1001.\n- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n\n### 11.3 Order Statuses\n\nThree independent status dimensions tracked on each order:\n\n**Order status** (`orders.status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Order created, awaiting processing |\n| `paid` | Payment confirmed |\n| `fulfilled` | All items shipped |\n| `cancelled` | Order cancelled before fulfillment |\n| `refunded` | Fully refunded |\n\n**Financial status** (`orders.financial_status`):\n\n| Status | Description |\n|---|---|\n| `pending` | Payment not yet confirmed |\n| `authorized` | Payment authorized but not captured |\n| `paid` | Payment captured successfully |\n| `partially_refunded` | Some items or partial amount refunded |\n| `refunded` | Fully refunded |\n| `voided` | Authorization voided (no capture occurred) |\n\n**Fulfillment status** (`orders.fulfillment_status`):\n\n| Status | Description |\n|---|---|\n| `unfulfilled` | No items have been shipped |\n| `partial` | Some items shipped, others pending |\n| `fulfilled` | All items shipped |\n\n### 11.4 Refund Processing\n\n**Service:** `App\\Services\\RefundService`\n\n**Input (RefundRequest):**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n| `reason` | string | No | Text reason for the refund. |\n| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n\n**Process:**\n\n```\nPROCEDURE processRefund(order, request):\n    1. Calculate remaining refundable amount:\n       refundable = order.total_amount - SUM(existing refunds for this order)\n\n    2. IF request.amount is set:\n           VALIDATE request.amount <= refundable\n       ELSE IF request.lines is set:\n           CALCULATE amount from specified lines and quantities\n       ELSE:\n           amount = refundable (full refund)\n\n    3. CALL PaymentProvider.refund(order's payment, amount)\n\n    4. CREATE Refund record with status = 'pending'\n\n    5. On provider confirmation:\n       UPDATE refund status = 'processed'\n\n    6. Calculate total refunded for this order:\n       IF total refunded = order.total_amount:\n           SET order.financial_status = 'refunded'\n           SET order.status = 'refunded'\n       ELSE:\n           SET order.financial_status = 'partially_refunded'\n\n    7. IF request.restock = true:\n       FOR EACH refunded line and quantity:\n           CALL InventoryService.restock(variant, quantity)\n\n    8. DISPATCH OrderRefunded event\n```\n\n### 11.5 Fulfillment\n\n**Service:** `App\\Services\\FulfillmentService`\n\n#### Fulfillment Guard\n\n**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\n| `financial_status` | Fulfillment Allowed? | Reason |\n|---|---|---|\n| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n| `authorized` | **No** | Payment authorized but not captured |\n| `paid` | **Yes** | Payment confirmed |\n| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n| `refunded` | **No** | Fully refunded, nothing to ship |\n| `voided` | **No** | Payment voided / cancelled |\n\nWhen 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\n#### Creating a Fulfillment\n\nInput:\n- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n- `trackingData` - tracking_company, tracking_number, tracking_url\n\n**Process:**\n\n```\nPROCEDURE createFulfillment(order, lines, trackingData):\n    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n       IF NOT: throw FulfillmentGuardException\n\n    2. FOR EACH line in request:\n       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n       unfulfilled = order_line.quantity - fulfilledSoFar\n       VALIDATE requested quantity <= unfulfilled\n\n    3. CREATE Fulfillment record with status = 'pending' and tracking data\n\n    4. CREATE fulfillment_lines for each order line and quantity\n\n    5. Determine new fulfillment status:\n       FOR EACH order_line in order:\n           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n           IF totalFulfilled < order_line.quantity:\n               allFulfilled = false\n\n       IF allFulfilled:\n           SET order.fulfillment_status = 'fulfilled'\n           SET order.status = 'fulfilled'\n       ELSE:\n           SET order.fulfillment_status = 'partial'\n\n    6. DISPATCH FulfillmentCreated event\n```\n\n#### Fulfillment Status Transitions (Admin Button Actions)\n\nFulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n\n```mermaid\nstateDiagram-v2\n    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n    pending --> shipped : Admin clicks \"Mark as shipped\"\n    shipped --> delivered : Admin clicks \"Mark as delivered\"\n\n    note right of pending : Fulfillment record created with tracking data\n    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n    note right of delivered : Sets delivered_at timestamp\n```\n\n**Admin buttons on each fulfillment card:**\n\n| Button | Shown When | Action |\n|---|---|---|\n| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n\nWhen transitioning to `shipped`:\n- Set `shipped_at` timestamp.\n- Dispatch `FulfillmentShipped` event.\n- Send email notification to customer with tracking information.\n\nWhen transitioning to `delivered`:\n- Dispatch `FulfillmentDelivered` event.\n\n### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n\nThe fulfillment guard interacts with inventory differently depending on the payment method:\n\n| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n|---|---|---|---|\n| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n\nFor 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\n### 11.7 Auto-Fulfillment for Digital Products\n\nWhen a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n\n1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n2. If yes: automatically create a fulfillment record covering all order lines:\n   - `status = 'delivered'` (skip pending and shipped)\n   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n   - `shipped_at = now`\n   - Set `order.fulfillment_status = 'fulfilled'`\n   - Set `order.status = 'fulfilled'`\n3. 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\nThis ensures digital product orders are immediately marked as complete after payment.\n\n### 11.6 Order Export\n\n`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n\n**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\nAll 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\n---\n\n## 12. Customer Accounts\n\n### 12.1 Registration\n\n**Service:** `App\\Services\\CustomerService`\n\n**Required fields:**\n\n| Field | Validation |\n|---|---|\n| `name` | Required, string |\n| `email` | Required, valid email, unique per store |\n| `password` | Required, minimum 8 characters |\n| `marketing_opt_in` | Optional, boolean, default false |\n\n- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n- Password hashed using Laravel's Hash facade (bcrypt by default).\n\n### 12.2 Authentication\n\n**Separate auth guard:** `customer`\n\nRequirements:\n- Configure a dedicated `customer` guard in auth config, using session driver.\n- Configure a dedicated `customers` provider using the Customer model.\n- Login: email + password, scoped to the current store.\n- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n\n**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\n### 12.3 Data Access\n\n- Customers can only view their own orders (scoped by customer_id match).\n- Customers can only manage their own addresses (scoped by customer_id match).\n- All customer queries include the StoreScope global scope for tenant isolation.\n\n### 12.4 Guest Cart to Customer Linking\n\n```\nPROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n    existingCustomer = find customer by email in store\n    IF existingCustomer:\n        LINK order to existingCustomer\n    ELSE:\n        CREATE new customer with:\n            - email = checkoutEmail\n            - password_hash = null (guest customer)\n            - store_id = storeId\n        LINK order to new customer\n\n    // Guest customer can later \"claim\" their account\n    // by setting a password via registration or account-claim flow\n```\n\n---\n\n## 13. Webhook Delivery System\n\n### 13.1 Subscription Model\n\nStores (or app installations) subscribe to event types via `webhook_subscriptions`.\n\n**Supported event types:**\n\n| Event Type | Triggered By |\n|---|---|\n| `order.created` | OrderCreated event |\n| `order.paid` | Payment captured successfully |\n| `order.fulfilled` | All order lines fulfilled |\n| `order.refunded` | Refund processed |\n| `product.created` | New product saved |\n| `product.updated` | Product modified |\n| `product.deleted` | Product deleted or archived |\n| `checkout.completed` | Checkout successfully completed |\n\n### 13.2 Delivery Process\n\n**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n\n**Job:** `App\\Jobs\\DeliverWebhook`\n\n```mermaid\nsequenceDiagram\n    participant DomainEvent\n    participant Listener\n    participant Queue\n    participant Job\n    participant TargetURL\n\n    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n    loop For each matching subscription\n        Listener->>Queue: Dispatch DeliverWebhook job\n    end\n    Queue->>Job: Process job\n    Job->>Job: Serialize event payload as JSON\n    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n    Job->>TargetURL: HTTP POST with headers and payload\n    TargetURL-->>Job: Response\n    Job->>Job: Record result in webhook_deliveries\n```\n\n**Required HTTP headers on delivery:**\n\n| Header | Value |\n|---|---|\n| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n| `X-Platform-Timestamp` | Unix timestamp of the event |\n| `Content-Type` | `application/json` |\n\n### 13.3 Retry Logic\n\nOn non-2xx response or connection timeout:\n\n| Attempt | Backoff Delay |\n|---|---|\n| 1 (initial) | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 (final) | 12 hours |\n\nAfter 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n\nUse Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n\n### 13.4 Circuit Breaker\n\n- Track consecutive failures per subscription (across different events).\n- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n- Log a warning and (future) send an admin notification.\n- Manual re-enable required by the merchant through the admin UI.\n- On each successful delivery, reset the consecutive failure counter to 0.\n\n**Service:** `App\\Services\\WebhookService`\n\n---\n\n## 14. Analytics\n\n### 14.1 Event Ingestion\n\n**Endpoint:** `POST /api/storefront/v1/analytics/events`\n\n**Request structure:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\nEvents are submitted in batches:\n\n```json\n{\n  \"events\": [\n    {\n      \"type\": \"page_view\",\n      \"session_id\": \"...\",\n      \"client_event_id\": \"...\",\n      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n      \"properties\": { ... }\n    }\n  ]\n}\n```\n\n**Event fields:**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n| `session_id` | string | Yes | Client session identifier |\n| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n\n**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\n**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n\n### 14.2 Aggregation\n\n**Job:** `App\\Jobs\\AggregateAnalytics`\n\n- Schedule: daily at 01:00 UTC\n- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n\n**Aggregated metrics:**\n\n| Metric | Calculation |\n|---|---|\n| `orders_count` | Count of `checkout_completed` events for the day |\n| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n| `add_to_cart_count` | Count of `add_to_cart` events |\n| `checkout_started_count` | Count of `checkout_started` events |\n\n---\n\n## 15. Media Processing\n\n### 15.1 Upload Flow\n\n```mermaid\nflowchart TD\n    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n    B --> C[Create product_media record with status = processing]\n    C --> D[Dispatch ProcessMediaUpload job to queue]\n    D --> E{Processing succeeds?}\n    E -->|Yes| F[Store resized versions, set status = ready]\n    E -->|No| G[Set status = failed, log error, keep original]\n```\n\n### 15.2 Processing\n\n**Job:** `App\\Jobs\\ProcessMediaUpload`\n\n**Resize targets:**\n\n| Key | Max Dimensions | Use Case |\n|---|---|---|\n| `thumbnail` | 150x150 px | Admin grid, small previews |\n| `small` | 300x300 px | Product cards, listings |\n| `medium` | 600x600 px | Product detail page |\n| `large` | 1200x1200 px | Full-size product image, zoom |\n\n**Processing rules:**\n- Maintain aspect ratio (contain within target dimensions, no cropping).\n- Generate WebP version alongside the original format if the server supports it.\n- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n- Maximum 3 retry attempts before permanent failure.\n\n### 15.3 Cleanup\n\nWhen a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n\n---\n\n## 16. Search (SQLite FTS5)\n\n### 16.1 Index Setup\n\nCreate an FTS5 virtual table named `products_fts` with these columns:\n\n| Column | Indexed | Description |\n|---|---|---|\n| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n| `title` | Yes | Product title, fully searchable |\n| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n| `vendor` | Yes | Vendor name |\n| `product_type` | Yes | Product type |\n| `tags` | Yes | Space-separated tag values |\n\n### 16.2 Index Synchronization\n\n**Observer:** `App\\Observers\\ProductObserver`\n\n| Model Event | FTS Action |\n|---|---|\n| `created` | Insert row into products_fts |\n| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n| `deleted` | Delete row from products_fts |\n\n**Data preparation before insert:**\n- Strip HTML tags from `description_html` to get plain text.\n- Convert JSON tags array to space-separated string.\n- Use empty string for null vendor and product_type fields.\n\n### 16.3 Search Query\n\n**Service:** `App\\Services\\SearchService`\n\n**Search algorithm:**\n\n```\nPROCEDURE search(store, queryString, limit = 20):\n    1. Sanitize queryString: escape FTS5 special characters\n    2. Append '*' to last token for prefix matching (autocomplete)\n       Example: \"running sh\" becomes \"running sh*\"\n    3. Execute FTS5 MATCH query:\n       - Filter by store_id\n       - Order by FTS5 relevance rank\n       - Limit results\n    4. Load matching Product models\n    5. Exclude products with status != 'active' or published_at IS NULL\n    6. RETURN ordered collection\n```\n\n### 16.4 Search Logging\n\nEach 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\n---\n\n## 17. Event and Listener Registry\n\nSummary of all domain events and their listeners/side effects:\n\n| Event | Listeners / Side Effects |\n|---|---|\n| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n| `CheckoutAddressed` | Recalculate pricing |\n| `CheckoutShippingSelected` | Recalculate pricing |\n| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n| `CheckoutExpired` | Release reserved inventory |\n| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n| `FulfillmentCreated` | Update order fulfillment status |\n| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n| `OrderCancelled` | Release reserved inventory, send cancellation email |\n\nAll events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n\n---\n\n## 18. Scheduled Jobs Registry\n\n| Job Class | Schedule | Description |\n|---|---|---|\n| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n\n**Queued jobs (dispatched on demand, not scheduled):**\n\n| Job Class | Description |\n|---|---|\n| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n\nSchedule registration belongs in `routes/console.php`.\n\n---\n\n## 19. Service Class Registry\n\n| Service | Namespace | Responsibility |\n|---|---|---|\n| ProductService | `App\\Services` | Product CRUD, status transitions |\n| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n| CheckoutService | `App\\Services` | Checkout state machine transitions |\n| DiscountService | `App\\Services` | Discount validation and application |\n| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n| PaymentService | `App\\Services` | Payment processing orchestration |\n| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n| RefundService | `App\\Services` | Refund processing and inventory restock |\n| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n| CustomerService | `App\\Services` | Customer registration, account management |\n| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n| SearchService | `App\\Services` | FTS5 search queries |\n| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n\n---\n\n## 20. Contracts (Interfaces)\n\n| Interface | Namespace | Implementing Classes |\n|---|---|---|\n| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n\n---\n\n## 21. Enum Registry\n\nAll enums live in `App\\Enums` and are string-backed.\n\n| Enum | Values (TitleCase keys, lowercase string values) |\n|---|---|\n| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n| VariantStatus | Active (`active`), Archived (`archived`) |\n| DiscountType | Code (`code`), Automatic (`automatic`) |\n| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |\n| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n\n---\n\n## 22. Value Object Registry\n\nAll value objects live in `App\\ValueObjects`.\n\n| Value Object | Fields | Purpose |\n|---|---|---|\n| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n| WebhookResult | success (bool), message (string) | Webhook processing result |\n| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n","structuredPatch":[{"oldStart":1678,"oldLines":7,"newStart":1678,"newLines":7,"lines":[" | DiscountType | Code (`code`), Automatic (`automatic`) |"," | DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |"," | CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |","-| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentPending (`payment_pending`), Completed (`completed`), Expired (`expired`) |","+| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |"," | PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |"," | PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |"," | OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"05ff9f8c-bd3c-4ea7-91f8-8707284c6925"}
{"parentUuid":"e7ade8aa-2ef5-4ee6-a528-0d8116133c3e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01E18YQPTGQFXkSczuC9sHCG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MP3hrq66ULqWmo4HoYwJdp","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","old_string":"| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |","new_string":"| expires checkout after timeout | checkout with payment_selected status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2305,"cache_read_input_tokens":69043,"output_tokens":1097,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":2305,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2YSj7SaBJky3Yk1291M","type":"assistant","uuid":"42d6df22-06bb-4dfd-b07c-0f9c400d4be5","timestamp":"2026-02-11T15:47:18.166Z"}
{"parentUuid":"42d6df22-06bb-4dfd-b07c-0f9c400d4be5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01MP3hrq66ULqWmo4HoYwJdp","toolUseID":"toolu_01MP3hrq66ULqWmo4HoYwJdp","timestamp":"2026-02-11T15:47:18.176Z","uuid":"4b6bb6b2-7f0b-43d0-a82d-9c3adc058874"}
{"parentUuid":"42d6df22-06bb-4dfd-b07c-0f9c400d4be5","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MP3hrq66ULqWmo4HoYwJdp","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md has been updated successfully."}]},"uuid":"e96b8f28-25f3-4c2f-975c-663631c1a4b1","timestamp":"2026-02-11T15:47:18.176Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","oldString":"| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |","newString":"| expires checkout after timeout | checkout with payment_selected status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |","originalFile":"# 09 - Implementation Roadmap and Pest Test Specification\n\n> Shopify-clone 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\n---\n\n## 1. Technology Decisions (Final)\n\nThese decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n\n| Concern | PDF Default | Our Decision | Rationale |\n|---------|-------------|--------------|-----------|\n| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n| Cache | Redis | File (Array for tests) | No Redis dependency |\n| Session | Redis | File | No Redis dependency |\n| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n| 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| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n\n---\n\n## 2. Implementation Phases (Build Order)\n\nEach 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\n### Phase Dependency Graph\n\n```mermaid\nflowchart TD\n    P1[Phase 1: Foundation]\n    P2[Phase 2: Catalog]\n    P3[Phase 3: Themes and Storefront Layout]\n    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    P5[Phase 5: Payments, Orders, Fulfillment]\n    P6[Phase 6: Customer Accounts]\n    P7[Phase 7: Admin Panel]\n    P8[Phase 8: Search]\n    P9[Phase 9: Analytics]\n    P10[Phase 10: Apps and Webhooks]\n    P11[Phase 11: Polish]\n    P12[Phase 12: Full Test Suite]\n\n    P1 --> P2\n    P1 --> P3\n    P2 --> P4\n    P3 --> P4\n    P4 --> P5\n    P5 --> P6\n    P5 --> P7\n    P2 --> P8\n    P5 --> P9\n    P5 --> P10\n    P6 --> P11\n    P7 --> P11\n    P8 --> P11\n    P9 --> P11\n    P10 --> P11\n    P11 --> P12\n```\n\n---\n\n### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n\n**Priority:** CRITICAL - everything depends on this.\n\n#### Step 1.1: Environment and Config\n\n| Config File | Setting | Value | Purpose |\n|-------------|---------|-------|---------|\n| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n| `.env` | `CACHE_STORE` | `file` | File-based caching |\n| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n| `config/session.php` | `driver` | `file` | Session driver |\n| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n| `config/cache.php` | `default` | `file` | Cache driver |\n| `config/queue.php` | `default` | `sync` | Queue driver |\n| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n\n#### Step 1.2: Core Migrations (Batch 1-2)\n\nCreate in this dependency order:\n\n1. `create_organizations_table` - no dependencies\n2. `create_stores_table` - FK: `organization_id`\n3. `create_store_domains_table` - FK: `store_id`\n4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n6. `create_store_settings_table` - PK: `store_id`\n\nAll 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\n#### Step 1.3: Core Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Organization` | `hasMany(Store::class)` |\n| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n| `StoreDomain` | `belongsTo(Store::class)` |\n| `User` | `belongsToMany(Store::class)` through `store_users` |\n| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n| `StoreSettings` | `belongsTo(Store::class)` |\n\nEach model must have:\n- Explicit return type declarations on all methods\n- Proper `$fillable` or `$guarded` arrays\n- `casts()` method for JSON/enum columns\n- Factory with meaningful default values\n- Seeder that creates sample data\n\n#### Step 1.4: Enums\n\n**Files to create in `app/Enums/`:**\n\n| Enum | Values |\n|------|--------|\n| `StoreStatus` | `Active`, `Suspended` |\n| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n\n#### Step 1.5: Tenant Resolution Middleware\n\n**File to create:** `App\\Http\\Middleware\\ResolveStore`\n\nRequirements:\n1. 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`\n2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n3. Return 404 if hostname not found\n4. Return 503 for suspended stores on storefront routes\n\n**Registration in `bootstrap/app.php`:**\n- Register middleware group `storefront` containing `ResolveStore`\n- Register middleware group `admin` containing `ResolveStore`\n\n#### Step 1.6: BelongsToStore Trait and Global Scope\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n\nApplied 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\n#### Step 1.7: Authentication\n\n**Admin auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Livewire component `Admin\\Auth\\Login` | Admin login form |\n| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n| Password reset | Standard Laravel flow with `Password::broker('users')` |\n\n**Customer auth requirements:**\n\n| Component | Description |\n|-----------|-------------|\n| Custom guard | `customer` guard with `customers` provider |\n| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n| Uniqueness | Customer email unique per store, not globally |\n| Rate limiting | 5 attempts per minute per IP |\n\n**Rate limiter registration:**\n- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)\n\n#### Step 1.8: Authorization\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Policy | `ProductPolicy` | Product authorization based on store role |\n| Policy | `OrderPolicy` | Order authorization based on store role |\n| Policy | `CollectionPolicy` | Collection authorization based on store role |\n| Policy | `DiscountPolicy` | Discount authorization based on store role |\n| Policy | `CustomerPolicy` | Customer authorization based on store role |\n| Policy | `StorePolicy` | Store authorization based on store role |\n| Policy | `PagePolicy` | Page authorization based on store role |\n| Policy | `ThemePolicy` | Theme authorization based on store role |\n| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |\n| Policy | `RefundPolicy` | Refund authorization based on store role |\n\nEach policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.\n\n**Permission Matrix:**\n\n| Permission | Owner | Admin | Staff | Support |\n|------------|:-----:|:-----:|:-----:|:-------:|\n| Manage store settings | Y | Y | N | N |\n| Delete store | Y | N | N | N |\n| Manage staff | Y | Y | N | N |\n| Manage products (CRUD) | Y | Y | Y | N |\n| Delete/archive products | Y | Y | N | N |\n| Manage orders | Y | Y | Y | Read-only |\n| Process refunds | Y | Y | N | N |\n| Create fulfillments | Y | Y | Y | N |\n| Manage discounts | Y | Y | Y | N |\n| Manage themes | Y | Y | N | N |\n| Manage apps | Y | Y | N | N |\n| View analytics | Y | Y | Y | N |\n| Manage shipping/tax | Y | Y | N | N |\n| View customers | Y | Y | Y | Y |\n| Update customers | Y | Y | Y | N |\n\n---\n\n### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)\n\n**Priority:** HIGH - storefront and orders depend on this.\n\n#### Step 2.1: Migrations (Batch 3-5)\n\nCreate in dependency order:\n\n1. `create_products_table` - FK: `store_id`\n2. `create_product_options_table` - FK: `product_id`\n3. `create_product_option_values_table` - FK: `product_option_id`\n4. `create_product_variants_table` - FK: `product_id`\n5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`\n6. `create_inventory_items_table` - FK: `store_id`, `variant_id`\n7. `create_collections_table` - FK: `store_id`\n8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`\n9. `create_product_media_table` - FK: `product_id`\n\n#### Step 2.2: Models with Relationships\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Product` | Product entity |\n| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |\n| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |\n| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |\n| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |\n| Model (with migration, factory, seeder) | `Collection` | Product collection/category |\n| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |\n| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |\n| `ProductOptionValue` | `belongsTo(ProductOption)` |\n| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |\n| `InventoryItem` | `belongsTo(ProductVariant)` |\n| `Collection` | `belongsToMany(Product)` via `collection_products` |\n| `ProductMedia` | `belongsTo(Product)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ProductStatus` | `Draft`, `Active`, `Archived` |\n| `VariantStatus` | `Active`, `Archived` |\n| `CollectionStatus` | `Draft`, `Active`, `Archived` |\n| `MediaType` | `Image`, `Video` |\n| `MediaStatus` | `Processing`, `Ready`, `Failed` |\n| `InventoryPolicy` | `Deny`, `Continue` |\n\n#### Step 2.3: Product Service\n\n**File to create:** `App\\Services\\ProductService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store $store, array $data): Product` | Creates product with nested variants/options |\n| `update(Product $product, array $data): Product` | Updates product |\n| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |\n| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |\n\n**File to create:** `App\\Services\\VariantMatrixService`\n\n| Method | Description |\n|--------|-------------|\n| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |\n\n**File to create:** `App\\Support\\HandleGenerator`\n\n| Method | Description |\n|--------|-------------|\n| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |\n\n#### Step 2.4: Inventory Service\n\n**File to create:** `App\\Services\\InventoryService`\n\n| Method | Description |\n|--------|-------------|\n| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |\n| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |\n| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |\n| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |\n| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |\n\nAll operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.\n\n#### Step 2.5: Media Upload\n\n**File to create:** `App\\Jobs\\ProcessMediaUpload`\n\nRequirements:\n- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)\n- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)\n\nLivewire file upload via `WithFileUploads` trait. Stored on local `public` disk.\n\n---\n\n### Phase 3: Themes, Pages, Navigation, Storefront Layout\n\n**Priority:** HIGH - storefront rendering depends on this.\n\n#### Step 3.1: Migrations (Batch 3)\n\n1. `create_themes_table` - FK: `store_id`\n2. `create_theme_files_table` - FK: `theme_id`\n3. `create_theme_settings_table` - PK: `theme_id`\n4. `create_pages_table` - FK: `store_id`\n5. `create_navigation_menus_table` - FK: `store_id`\n6. `create_navigation_items_table` - FK: `menu_id`\n\n#### Step 3.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Theme` | Store theme entity |\n| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |\n| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |\n| Model (with migration, factory, seeder) | `Page` | CMS page |\n| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |\n| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |\n| `ThemeFile` | `belongsTo(Theme)` |\n| `ThemeSettings` | `belongsTo(Theme)` |\n| `Page` | `belongsTo(Store)` |\n| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |\n| `NavigationItem` | `belongsTo(NavigationMenu)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `ThemeStatus` | `Draft`, `Published` |\n| `PageStatus` | `Draft`, `Published`, `Archived` |\n| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |\n\n#### Step 3.3: Storefront Blade Layout\n\n**Directory structure for `resources/views/storefront/`:**\n\n| Directory | Files |\n|-----------|-------|\n| `layouts/` | `app.blade.php` |\n| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |\n| `checkout/` | `index.blade.php`, `confirmation.blade.php` |\n| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |\n| `account/orders/` | `index.blade.php`, `show.blade.php` |\n| `account/addresses/` | `index.blade.php` |\n| `errors/` | `404.blade.php`, `503.blade.php` |\n| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |\n\nBase layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.\n\n**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.\n\n#### Step 3.4: Storefront Livewire Components\n\n**Files to create:**\n\n| Component | Purpose |\n|-----------|---------|\n| `Storefront\\Home` | Dynamic sections based on theme settings |\n| `Storefront\\Collections\\Index` | All collections grid |\n| `Storefront\\Collections\\Show` | Product grid with filters/sort/pagination |\n| `Storefront\\Products\\Show` | Variant selection, image gallery, add-to-cart |\n| `Storefront\\Cart\\Show` | Full cart page with line items and totals |\n| `Storefront\\CartDrawer` | Slide-out cart drawer (always in layout) |\n| `Storefront\\Search\\Index` | Search results with filters |\n| `Storefront\\Search\\Modal` | Search modal with autocomplete |\n| `Storefront\\Pages\\Show` | Static CMS page rendering |\n\n#### Step 3.5: Navigation Service\n\n**File to create:** `App\\Services\\NavigationService`\n\n| Method | Description |\n|--------|-------------|\n| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |\n| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |\n\nCaches per store with 5-minute TTL.\n\n---\n\n### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes\n\n**Priority:** HIGH - core shopping flow.\n\n#### Step 4.1: Migrations (Batch 4-5)\n\n1. `create_carts_table` - FK: `store_id`, `customer_id`\n2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`\n3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`\n4. `create_shipping_zones_table` - FK: `store_id`\n5. `create_shipping_rates_table` - FK: `zone_id`\n6. `create_tax_settings_table` - PK: `store_id`\n7. `create_discounts_table` - FK: `store_id`\n\n#### Step 4.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |\n| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |\n| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |\n| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |\n| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |\n| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |\n| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |\n| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |\n| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |\n| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |\n| `ShippingRate` | `belongsTo(ShippingZone)` |\n| `TaxSettings` | `belongsTo(Store)` |\n| `Discount` | `belongsTo(Store)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `CartStatus` | `Active`, `Converted`, `Abandoned` |\n| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |\n| `DiscountType` | `Code`, `Automatic` |\n| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |\n| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |\n| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |\n| `TaxMode` | `Manual`, `Provider` |\n\n#### Step 4.3: Cart Service\n\n**File to create:** `App\\Services\\CartService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |\n| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |\n| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |\n| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |\n| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |\n| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |\n\nAll mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.\n\n#### Step 4.4: Discount Service\n\n**File to create:** `App\\Services\\DiscountService`\n\n| Method | Description |\n|--------|-------------|\n| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |\n| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |\n\nCase-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.\n\n#### Step 4.5: Shipping Calculator\n\n**File to create:** `App\\Services\\ShippingCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |\n| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |\n\nZone matching: checks `countries_json` for country code, then `regions_json` for region code.\n\n#### Step 4.6: Tax Calculator\n\n**File to create:** `App\\Services\\TaxCalculator`\n\n| Method | Description |\n|--------|-------------|\n| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |\n| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |\n| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |\n\nAll math uses integers. Rates stored in basis points (1900 = 19%).\n\n#### Step 4.7: Pricing Engine\n\n**File to create:** `App\\Services\\PricingEngine`\n\nMethod: `calculate(Checkout $checkout): PricingResult`\n\n**Pipeline steps:**\n1. Line subtotals: `unit_price * quantity` per line\n2. Cart subtotal: sum of line subtotals\n3. Discount: apply rules, allocate proportionally\n4. Discounted subtotal: subtotal minus discount\n5. Shipping: based on selected method/rate\n6. Tax: on discounted amounts (plus shipping if taxable)\n7. Total: discounted_subtotal + shipping + tax_total\n\nResult stored in `checkouts.totals_json` as snapshot.\n\n**Value objects to create:**\n\n| Type | Name | Fields |\n|------|------|--------|\n| Value Object | `App\\ValueObjects\\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |\n| Value Object | `App\\ValueObjects\\TaxLine` | name, rate (basis points), amount (cents) |\n\n#### Step 4.8: Checkout State Machine\n\n**File to create:** `App\\Services\\CheckoutService`\n\n| Transition | Method | Requirements |\n|------------|--------|-------------|\n| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |\n| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |\n| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |\n| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |\n| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |\n| Job | `App\\Jobs\\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |\n\n#### Step 4.9: Storefront Cart/Checkout UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |\n| `Storefront\\Cart\\Show` | Full cart page with same functionality plus shipping estimate |\n| `Storefront\\Checkout\\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |\n| `Storefront\\Checkout\\Confirmation` | Order confirmation: order number, items, totals, next steps |\n\n---\n\n### Phase 5: Payments, Orders, Fulfillment\n\n**Priority:** HIGH - completes the purchase flow.\n\n#### Step 5.1: Migrations (Batch 5-7)\n\n1. `create_customers_table` - FK: `store_id`\n2. `create_customer_addresses_table` - FK: `customer_id`\n3. `create_orders_table` - FK: `store_id`, `customer_id`\n4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)\n5. `create_payments_table` - FK: `order_id`\n6. `create_refunds_table` - FK: `order_id`, `payment_id`\n7. `create_fulfillments_table` - FK: `order_id`\n8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`\n\n#### Step 5.2: Models\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Model (with migration, factory, seeder) | `Customer` | Store customer entity |\n| Model (with migration, factory, seeder) | `CustomerAddress` | Customer address |\n| Model (with migration, factory, seeder) | `Order` | Order entity |\n| Model (with migration, factory, seeder) | `OrderLine` | Order line item |\n| Model (with migration, factory, seeder) | `Payment` | Payment record |\n| Model (with migration, factory, seeder) | `Refund` | Refund record |\n| Model (with migration, factory, seeder) | `Fulfillment` | Fulfillment record |\n| Model (with migration, factory, seeder) | `FulfillmentLine` | Fulfillment line item |\n\n**Relationship definitions:**\n\n| Model | Key Relationships |\n|-------|-------------------|\n| `Customer` | `belongsTo(Store)`, `hasMany(CustomerAddress)`, `hasMany(Order)`, `hasMany(Cart)` |\n| `CustomerAddress` | `belongsTo(Customer)` |\n| `Order` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(OrderLine)`, `hasMany(Payment)`, `hasMany(Refund)`, `hasMany(Fulfillment)` |\n| `OrderLine` | `belongsTo(Order)`, `belongsTo(Product)` (nullable), `belongsTo(ProductVariant)` (nullable) |\n| `Payment` | `belongsTo(Order)` |\n| `Refund` | `belongsTo(Order)`, `belongsTo(Payment)` |\n| `Fulfillment` | `belongsTo(Order)`, `hasMany(FulfillmentLine)` |\n| `FulfillmentLine` | `belongsTo(Fulfillment)`, `belongsTo(OrderLine)` |\n\n**Enums to create:**\n\n| Enum | Values |\n|------|--------|\n| `OrderStatus` | `Pending`, `Paid`, `Fulfilled`, `Cancelled`, `Refunded` |\n| `FinancialStatus` | `Pending`, `Authorized`, `Paid`, `PartiallyRefunded`, `Refunded`, `Voided` |\n| `FulfillmentStatus` | `Unfulfilled`, `Partial`, `Fulfilled` |\n| `PaymentMethod` | `CreditCard`, `Paypal`, `BankTransfer` |\n| `PaymentStatus` | `Pending`, `Captured`, `Failed`, `Refunded` |\n| `RefundStatus` | `Pending`, `Processed`, `Failed` |\n| `FulfillmentShipmentStatus` | `Pending`, `Shipped`, `Delivered` |\n\n#### Step 5.3: Payment Service (Mock PSP)\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Enum | `App\\Enums\\PaymentMethod` | CreditCard, Paypal, BankTransfer |\n| Interface | `App\\Contracts\\PaymentProvider` | Defines charge, refund methods |\n| Service | `App\\Services\\Payments\\MockPaymentProvider` | Implements PaymentProvider with in-process mock logic |\n\n**PaymentProvider interface methods:**\n\n| Method | Description |\n|--------|-------------|\n| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |\n| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |\n\n**MockPaymentProvider requirements:**\n- No external API calls, no webhooks, no redirects\n- Magic card numbers: `4242424242424242` (success), `4000000000000002` (decline), `4000000000009995` (insufficient funds)\n- Credit card / PayPal: instant capture, returns PaymentResult with status=captured\n- Bank transfer: deferred capture, returns PaymentResult with status=pending\n- Generates mock reference IDs (`mock_` + random string)\n\n**Registration:** Bind `PaymentProvider` interface to `MockPaymentProvider` in `AppServiceProvider`.\n\n#### Step 5.3b: Admin Payment Confirmation\n\n**Files to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Job | `App\\Jobs\\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |\n\n**Admin \"Confirm Payment\" action** (in OrderService or dedicated action class):\n1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`\n2. Update `financial_status` to `paid`, `status` to `paid`\n3. Update payment record status to `captured`\n4. Commit reserved inventory\n5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment\n6. Dispatch `OrderPaid` event\n\n#### Step 5.4: Order Service\n\n**File to create:** `App\\Services\\OrderService`\n\n| Method | Description |\n|--------|-------------|\n| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |\n| `generateOrderNumber(Store $store): string` | Sequential per store: #1001, #1002, etc. |\n| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |\n\nOrder lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.\n\n#### Step 5.5: Refund Service\n\n**File to create:** `App\\Services\\RefundService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |\n\n- Partial refunds: `financial_status` -> `partially_refunded`\n- Full refunds: `financial_status` -> `refunded`\n- Restock flag: calls `InventoryService::restock()` for each line\n\n#### Step 5.6: Fulfillment Service\n\n**File to create:** `App\\Services\\FulfillmentService`\n\n| Method | Description |\n|--------|-------------|\n| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |\n| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |\n| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |\n\nUpdates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).\n\n**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).\n\n**Events to create:**\n\n| Type | Name | Purpose |\n|------|------|---------|\n| Event | `OrderCreated` | Dispatched when order is created from checkout |\n| Event | `OrderPaid` | Dispatched when payment is captured |\n| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |\n| Event | `OrderCancelled` | Dispatched when order is cancelled |\n| Event | `OrderRefunded` | Dispatched when refund is processed |\n\n---\n\n### Phase 6: Customer Accounts\n\n**Priority:** MEDIUM - enhances the shopping experience.\n\n#### Step 6.1: Customer Auth\n\n- `Customer` model with custom `customer` guard (already configured in Phase 1)\n- Custom `CustomerUserProvider` - scopes queries by `store_id`\n- Customer email unique per store, not globally\n\n#### Step 6.2: Customer Account Pages (Livewire)\n\n**Files to create:**\n\n| Component | Route | Description |\n|-----------|-------|-------------|\n| `Storefront\\Account\\Auth\\Login` | `GET /account/login` | Login form |\n| `Storefront\\Account\\Auth\\Register` | `GET /account/register` | Registration form |\n| `Storefront\\Account\\Dashboard` | `GET /account` | Overview with recent orders |\n| `Storefront\\Account\\Orders\\Index` | `GET /account/orders` | Paginated order history |\n| `Storefront\\Account\\Orders\\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |\n| `Storefront\\Account\\Addresses\\Index` | `GET /account/addresses` | Address book CRUD |\n\nLogout: `POST /account/logout` handled by Livewire action. Invalidates session, regenerates CSRF token, redirects to `/account/login`.\n\n---\n\n### Phase 7: Admin Panel\n\n**Priority:** MEDIUM - merchant management interface.\n\n#### Step 7.1: Admin Layout\n\n**File to create:** `resources/views/livewire/admin/layout/app.blade.php`\n\n**Components to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Layout\\Sidebar` | Fixed left sidebar (256px desktop, overlay mobile), navigation groups, Flux `brand`, `icon`, `separator` |\n| `Admin\\Layout\\TopBar` | Store selector dropdown, user profile, hamburger toggle (mobile) |\n| `Admin\\Layout\\Breadcrumbs` | Dynamic breadcrumb trail |\n\nToast notifications via Livewire events.\n\n#### Step 7.2: Dashboard\n\n**File to create:** `Admin\\Dashboard` Livewire component\n\nRequirements:\n- KPI tiles: total sales (period), order count, average order value, conversion rate\n- Orders chart (last 30 days line chart)\n- Recent orders table (last 10)\n- Date range filtering via Livewire properties\n\n#### Step 7.3: Product Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Products\\Index` | Product list with search, status filter, bulk actions (archive, delete), pagination |\n| `Admin\\Products\\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |\n\n> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\\Livewire\\Admin\\{Resource}\\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.\n\nVariants builder: dynamic form to add options (Size, Color, etc.) and values, auto-generates variant matrix. Each variant row has price, SKU, barcode, weight, inventory quantity fields.\n\nMedia upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.\n\n#### Step 7.4: Order Management\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Orders\\Index` | Order list with status filters, search by order number/email, date range |\n| `Admin\\Orders\\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |\n\nFulfillment modal: select lines and quantities, add tracking info, submit.\nRefund modal: enter amount (partial or full), reason, restock checkbox, submit.\n\n#### Step 7.5: Other Admin Sections\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Admin\\Collections\\Index` | Collection list |\n| `Admin\\Collections\\Form` | Shared form component for create/edit collection with product picker, reorder products |\n| `Admin\\Customers\\Index` | Customer list with search |\n| `Admin\\Customers\\Show` | Customer detail: info, orders, addresses |\n| `Admin\\Discounts\\Index` | Discount list with status/type filters |\n| `Admin\\Discounts\\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |\n| `Admin\\Settings\\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |\n| `Admin\\Settings\\Shipping` | Shipping zones CRUD, rates CRUD per zone |\n| `Admin\\Settings\\Taxes` | Tax mode toggle, manual rate configuration |\n| `Admin\\Themes\\Index` | Theme cards with Publish/Duplicate/Delete actions |\n| `Admin\\Themes\\Editor` | Left: sections. Center: preview. Right: settings. |\n| `Admin\\Pages\\Index` | Content pages list |\n| `Admin\\Pages\\Form` | Shared form component for create/edit page with rich text editor |\n| `Admin\\Navigation\\Index` | Menu management with drag-and-drop item ordering |\n| `Admin\\Analytics\\Index` | Sales chart, traffic, funnel visualization, date range |\n| `Admin\\Search\\Settings` | Synonyms, stop words, reindex button |\n| `Admin\\Apps\\Index` | App directory |\n| `Admin\\Apps\\Show` | Installed app detail |\n| `Admin\\Developers\\Index` | API token management, webhook subscriptions |\n\n---\n\n### Phase 8: Search\n\n**Priority:** LOW - enhances product discovery.\n\n#### Step 8.1: Migrations\n\n1. `create_search_settings_table` - PK: `store_id`\n2. `create_search_queries_table` - FK: `store_id`\n3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table\n\n#### Step 8.2: Search Service\n\n**File to create:** `App\\Services\\SearchService`\n\n| Method | Description |\n|--------|-------------|\n| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |\n| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |\n| `syncProduct(Product $product): void` | Upsert into FTS5 index |\n| `removeProduct(int $productId): void` | Delete from FTS5 index |\n\n**File to create:** `App\\Observers\\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.\n\n#### Step 8.3: Search UI\n\n**Files to create:**\n\n| Component | Description |\n|-----------|-------------|\n| `Storefront\\Search\\Modal` | Search-as-you-type modal with autocomplete results |\n| `Storefront\\Search\\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |\n\n---\n\n### Phase 9: Analytics\n\n**Priority:** LOW - reporting and insights.\n\n#### Step 9.1: Migrations\n\n1. `create_analytics_events_table` - FK: `store_id`, `customer_id`\n2. `create_analytics_daily_table` - composite PK: `store_id` + `date`\n\n#### Step 9.2: Analytics Service\n\n**File to create:** `App\\Services\\AnalyticsService`\n\n| Method | Description |\n|--------|-------------|\n| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |\n| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |\n\nEvent types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.\n\n**File to create:** `App\\Jobs\\AggregateAnalytics`\n\nRequirements:\n- Runs daily via `routes/console.php`\n- Aggregates raw events into `analytics_daily` rows\n- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count\n\n---\n\n### Phase 10: Apps and Webhooks\n\n**Priority:** LOW - extensibility.\n\n#### Step 10.1: Migrations\n\n1. `create_apps_table`\n2. `create_app_installations_table` - FK: `store_id`, `app_id`\n3. `create_oauth_clients_table` - FK: `app_id`\n4. `create_oauth_tokens_table` - FK: `installation_id`\n5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`\n6. `create_webhook_deliveries_table` - FK: `subscription_id`\n\n#### Step 10.2: Webhook Service\n\n**File to create:** `App\\Services\\WebhookService`\n\n| Method | Description |\n|--------|-------------|\n| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |\n| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |\n| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |\n\n**File to create:** `App\\Jobs\\DeliverWebhook`\n\nRequirements:\n- HTTP POST to target URL with JSON payload\n- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`\n- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)\n- Records response in `webhook_deliveries`\n- Circuit breaker: after 5 consecutive failures, pause subscription\n\n---\n\n### Phase 11: Polish\n\n**Priority:** LOW but important for completeness.\n\n1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals\n2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints\n3. Dark mode: `dark:` prefix on all storefront views and admin views\n4. Error pages: styled 404 and 503 pages matching the storefront theme\n5. Structured logging: JSON channel in `config/logging.php`\n6. Seed data: comprehensive seeders for demo stores\n\n---\n\n### Phase 12: Full Test Suite Execution\n\n**Priority:** Runs after all phases are complete.\n\n**Verification steps:**\n\n1. Run the full unit and feature test suite and confirm all tests pass\n2. Run all browser tests and confirm they pass\n3. Run the code style formatter/checker and confirm conformance\n4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors\n5. Manual smoke: visit storefront, navigate products, add to cart, checkout\n6. Manual smoke: visit admin login, authenticate, manage products and orders\n\n---\n\n## 3. Pest Unit/Feature Test Specification\n\nAll tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.\n\n### Test Directory Structure\n\n| Directory | Files |\n|-----------|-------|\n| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |\n| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |\n| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |\n| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |\n| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |\n| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |\n| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |\n| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |\n| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |\n| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |\n| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |\n| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |\n| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |\n| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |\n| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |\n\n---\n\n### Unit Tests\n\n---\n\n#### tests/Unit/PricingEngineTest.php\n\n**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |\n| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |\n| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |\n| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |\n| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |\n| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |\n| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |\n| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |\n| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |\n| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |\n| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |\n| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |\n| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |\n\n---\n\n#### tests/Unit/DiscountCalculatorTest.php\n\n**Setup:** Instantiate `DiscountService` with mock store and cart.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |\n| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |\n| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |\n| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |\n| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |\n| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |\n| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |\n| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |\n| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |\n| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |\n| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |\n| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |\n| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |\n\n---\n\n#### tests/Unit/TaxCalculatorTest.php\n\n**Setup:** Instantiate `TaxCalculator` directly.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |\n| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |\n| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |\n| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |\n| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |\n| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |\n| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |\n\n---\n\n#### tests/Unit/ShippingCalculatorTest.php\n\n**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| matches a zone by country code | zone countries_json: [\"DE\",\"AT\",\"CH\"]; address country: \"DE\" | zone matched | happy path |\n| matches a zone by region code | zone regions_json: [\"US-NY\",\"US-CA\"]; address: country \"US\", province_code \"US-NY\" | zone matched | happy path |\n| returns empty when no zone matches the address | zone countries_json: [\"DE\"]; address country: \"FR\" | empty collection returned | edge case |\n| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |\n| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |\n| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |\n| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |\n| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |\n| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |\n\n---\n\n#### tests/Unit/CartVersionTest.php\n\n**Setup:** Instantiate Cart model or CartService.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| starts at version 1 | create new cart | cart_version = 1 | happy path |\n| increments version on add line | add a line to cart | cart_version = 2 | happy path |\n| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |\n| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |\n| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |\n\n---\n\n#### tests/Unit/HandleGeneratorTest.php\n\n**Setup:** Instantiate `HandleGenerator` with mock database queries.\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a slug from title | title: \"My Amazing Product\" | handle = \"my-amazing-product\" | happy path |\n| appends suffix on collision | title: \"T-Shirt\" (handle \"t-shirt\" already exists) | handle = \"t-shirt-1\" | edge case |\n| increments suffix on multiple collisions | \"t-shirt\" and \"t-shirt-1\" both exist | handle = \"t-shirt-2\" | edge case |\n| handles special characters | title: \"Loewe's Fall/Winter 2026\" | handle is a valid slug (e.g., \"loewes-fallwinter-2026\") | edge case |\n| excludes current record id from collision check | product #5 with handle \"t-shirt\", updating product #5 | handle = \"t-shirt\" (no suffix needed) | edge case |\n| scopes uniqueness check to store | store 1 has \"t-shirt\", generating for store 2 | handle = \"t-shirt\" (no suffix needed) | edge case |\n\n---\n\n### Feature Tests\n\nAll feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.\n\n**Shared helper setup** (in `tests/Pest.php` or trait):\n- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container\n- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session\n- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard\n\n---\n\n#### tests/Feature/Tenancy/TenantResolutionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| resolves store from hostname for storefront requests | store with domain \"acme-fashion.test\"; GET \"/\" with Host header \"acme-fashion.test\" | 200 response, current_store bound in container | happy path |\n| returns 404 for unknown hostname | GET \"/\" with Host header \"nonexistent.test\" | 404 response | validation |\n| returns 503 for suspended store on storefront | store with status \"suspended\"; GET \"/\" with Host header for suspended store | 503 response | validation |\n| resolves store from session for admin requests | login as admin, set current_store_id in session; GET \"/admin\" | 200 response, correct store in context | happy path |\n| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET \"/admin\" | 403 response | authorization |\n| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key \"store_domain:{hostname}\" exists | happy path |\n\n---\n\n#### tests/Feature/Tenancy/StoreIsolationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |\n| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |\n| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |\n| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |\n| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |\n\n---\n\n#### tests/Feature/Auth/AdminAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin login page | GET /admin/login | 200 response, sees \"Login\" text | happy path |\n| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |\n| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees \"Invalid credentials\" | validation |\n| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic \"Invalid credentials\" error | validation |\n| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |\n| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |\n| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |\n| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |\n| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |\n| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |\n\n---\n\n#### tests/Feature/Auth/CustomerAuthTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |\n| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |\n| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |\n| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |\n| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |\n| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |\n| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |\n| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |\n| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |\n| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |\n\n---\n\n#### tests/Feature/Auth/SanctumTokenTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |\n| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |\n| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |\n| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |\n| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |\n\n---\n\n#### tests/Feature/Products/ProductCrudTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |\n| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |\n| generates a unique handle from the title | create product with title \"Summer T-Shirt\" | handle = \"summer-t-shirt\" | happy path |\n| appends suffix when handle collides | create two products with title \"T-Shirt\" | handles are \"t-shirt\" and \"t-shirt-1\" | edge case |\n| updates a product | create product, then update title and description | DB reflects changes | happy path |\n| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |\n| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |\n| transitions product from active to archived | active product; transition to archived | status = archived | happy path |\n| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |\n| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |\n| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |\n| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |\n| searches products by title | product titled \"Organic Cotton Hoodie\"; search \"cotton\" | product found in results | happy path |\n\n---\n\n#### tests/Feature/Products/VariantTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |\n| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |\n| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |\n| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |\n| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |\n| validates SKU uniqueness within store | create variant with SKU \"TSH-001\" in store A; attempt to create another variant with same SKU in store A | validation error | validation |\n| allows duplicate SKU across different stores | SKU \"TSH-001\" in store A; create variant with \"TSH-001\" in store B | success | edge case |\n| allows null SKUs | create two variants with null SKU | both created successfully | edge case |\n\n---\n\n#### tests/Feature/Products/InventoryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |\n| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |\n| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |\n| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |\n| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |\n| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |\n| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |\n| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |\n\n---\n\n#### tests/Feature/Products/CollectionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a collection with a unique handle | create collection titled \"Summer Sale\" | handle = \"summer-sale\", saved in DB | happy path |\n| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |\n| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |\n| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |\n| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |\n| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |\n| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |\n\n---\n\n#### tests/Feature/Products/MediaUploadTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |\n| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |\n| rejects non-image file types | attempt to upload a .txt file | validation error | validation |\n| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |\n| reorders media positions | upload 3 images, reorder | position values updated | happy path |\n| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |\n\n---\n\n#### tests/Feature/Cart/CartServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |\n| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |\n| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |\n| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |\n| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |\n| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |\n| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |\n| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |\n| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |\n| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |\n| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |\n| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |\n\n---\n\n#### tests/Feature/Cart/CartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |\n| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |\n| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |\n| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |\n| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |\n| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |\n| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |\n\n---\n\n#### tests/Feature/Checkout/CheckoutFlowTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |\n| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |\n| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |\n| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |\n| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |\n\n---\n\n#### tests/Feature/Checkout/CheckoutStateTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |\n| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |\n| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |\n| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |\n| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |\n| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |\n| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |\n| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |\n| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |\n\n---\n\n#### tests/Feature/Checkout/PricingIntegrationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |\n| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |\n| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |\n| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |\n| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |\n\n---\n\n#### tests/Feature/Checkout/DiscountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| applies a valid percent discount code at checkout | discount: 10%, code \"SAVE10\"; apply to checkout with subtotal 5000 | discount = 500 | happy path |\n| applies a valid fixed discount code at checkout | discount: 500 fixed, code \"5OFF\" | discount = 500 | happy path |\n| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |\n| rejects expired discount at checkout | discount ended yesterday | error returned | validation |\n| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |\n| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |\n\n---\n\n#### tests/Feature/Checkout/ShippingTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |\n| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |\n| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |\n| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |\n| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |\n\n---\n\n#### tests/Feature/Checkout/TaxTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |\n| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |\n| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |\n| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |\n\n---\n\n#### tests/Feature/Orders/OrderCreationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |\n| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |\n| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |\n| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |\n| marks cart as converted | create order from checkout | cart status = converted | happy path |\n| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |\n| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |\n| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |\n| sets email from checkout on the order | guest checkout with email \"test@example.com\" | order.email = \"test@example.com\" | happy path |\n\n---\n\n#### tests/Feature/Orders/RefundTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a full refund | order with total 5000, payment captured; refund 5000 | refund in DB, financial_status = refunded | happy path |\n| creates a partial refund | order total 5000; refund 2000 | financial_status = partially_refunded | happy path |\n| rejects refund exceeding payment amount | payment amount 5000; attempt to refund 6000 | validation error | validation |\n| restocks inventory when restock flag is true | refund with restock=true, qty 2 | inventory on_hand increased by 2 | happy path |\n| does not restock when restock flag is false | refund with restock=false | inventory unchanged | edge case |\n| only allows admin or owner to process refunds | staff user attempts refund | 403 Forbidden | authorization |\n| records refund reason | refund with reason \"Customer requested\" | refund.reason = \"Customer requested\" | happy path |\n\n---\n\n#### tests/Feature/Orders/FulfillmentTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a fulfillment for specific order lines | order with 2 lines (qty 3 and qty 2); fulfill line 1 qty 3 | fulfillment created, fulfillment_lines has 1 row | happy path |\n| updates order fulfillment status to partial | fulfill only some lines | order.fulfillment_status = partial | happy path |\n| updates order fulfillment status to fulfilled when all lines done | fulfill all remaining lines | order.fulfillment_status = fulfilled | happy path |\n| adds tracking information | ship fulfillment with tracking_company=\"DHL\", tracking_number=\"123456\" | tracking info saved, shipped_at set | happy path |\n| transitions fulfillment from pending to shipped | ship fulfillment | status = shipped | happy path |\n| transitions fulfillment from shipped to delivered | deliver fulfillment | status = delivered | happy path |\n| prevents fulfilling more than ordered quantity | order line qty 2; attempt to fulfill qty 3 | validation error | validation |\n| fulfillment guard blocks fulfillment when financial_status is pending | order with financial_status=pending (bank transfer) | FulfillmentGuardException thrown | validation |\n| fulfillment guard allows fulfillment when financial_status is paid | order with financial_status=paid | fulfillment created successfully | happy path |\n| fulfillment guard allows fulfillment when financial_status is partially_refunded | order with financial_status=partially_refunded | fulfillment created successfully | edge case |\n| auto-fulfills digital products on payment confirmation | order with all digital items, financial_status transitions to paid | fulfillment auto-created with status=delivered | happy path |\n| only allows admin, owner, or staff to create fulfillments | support user attempts to create fulfillment | 403 Forbidden | authorization |\n\n---\n\n#### tests/Feature/Payments/MockPaymentProviderTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| charges credit card with success card number | checkout with card 4242424242424242 | PaymentResult success=true, status=captured | happy path |\n| declines credit card with decline card number | checkout with card 4000000000000002 | PaymentResult success=false, errorCode=card_declined | edge case |\n| returns insufficient funds for that card number | checkout with card 4000000000009995 | PaymentResult success=false, errorCode=insufficient_funds | edge case |\n| charges PayPal successfully | checkout with payment_method=paypal | PaymentResult success=true, status=captured | happy path |\n| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |\n| generates mock reference ID | any charge | provider_payment_id starts with \"mock_\" | happy path |\n\n---\n\n#### tests/Feature/Payments/PaymentServiceTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| processes credit card payment and creates order as paid | checkout with credit card | order created with financial_status=paid, inventory committed | happy path |\n| processes PayPal payment and creates order as paid | checkout with PayPal | order created with financial_status=paid, inventory committed | happy path |\n| processes bank transfer and creates order as pending | checkout with bank transfer | order created with financial_status=pending, inventory reserved but not committed | happy path |\n| resolves MockPaymentProvider from container | resolve PaymentProvider interface | resolved provider is MockPaymentProvider | happy path |\n| creates a payment record with correct method | charge with credit_card | payment row in DB with method=credit_card, provider=mock | happy path |\n\n---\n\n#### tests/Feature/Payments/BankTransferConfirmationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |\n| cannot confirm payment for non-bank-transfer orders | order with payment_method=credit_card | error thrown | validation |\n| cannot confirm already confirmed payment | order with financial_status=paid | error thrown | validation |\n| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |\n| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |\n| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |\n\n---\n\n#### tests/Feature/Customers/CustomerAccountTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the customer dashboard | acting as customer; GET /account | 200 response, sees customer name | happy path |\n| lists customer orders | customer with 3 orders; GET /account/orders | sees all 3 order numbers | happy path |\n| shows order detail | customer with order #1001; GET /account/orders/1001 | sees order items and totals | happy path |\n| prevents accessing another customers orders | customer A logged in; GET /account/orders/{customerB-order-number} | 404 response | authorization |\n| redirects unauthenticated requests to login | GET /account without auth | redirected to /account/login | authorization |\n| updates customer profile | update name and marketing_opt_in | changes persisted in DB | happy path |\n\n---\n\n#### tests/Feature/Customers/AddressManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists saved addresses | customer with 2 addresses; GET /account/addresses | sees both addresses | happy path |\n| creates a new address | submit address form | address saved in DB | happy path |\n| updates an existing address | change city on existing address | updated in DB | happy path |\n| deletes an address | delete address | removed from DB | happy path |\n| sets a default address | mark address as default | is_default=1, other addresses is_default=0 | happy path |\n| validates required address fields | submit with missing address1 | validation error | validation |\n| prevents managing another customers addresses | customer A logged in, attempt to edit customer B address | 403 or 404 response | authorization |\n\n---\n\n#### tests/Feature/Admin/DashboardTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the admin dashboard | acting as admin; GET /admin | 200 response, sees \"Dashboard\" | happy path |\n| shows KPI tiles with correct data | store has 5 orders totaling 25000 cents | sees order count 5, total revenue formatted | happy path |\n| restricts dashboard to authenticated admins | unauthenticated GET /admin | redirected to /admin/login | authorization |\n| filters metrics by date range | orders across multiple dates; filter to last 7 days | only matching orders included in KPIs | happy path |\n\n---\n\n#### tests/Feature/Admin/ProductManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with pagination | create 25 products; GET /admin/products | first page shows 15 (or configured per-page), pagination controls visible | happy path |\n| creates a product via admin form | Livewire test: fill title, description, submit | product in DB | happy path |\n| edits a product via admin form | Livewire test: change title, submit | product updated | happy path |\n| bulk archives selected products | select 3 products, trigger bulk archive | all 3 status = archived | happy path |\n| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |\n| manages variants from the product form | add options and values via Livewire | variants generated | happy path |\n| restricts product management to authorized roles | support user GET /admin/products: can see list (read-only); support user attempt to create product | 403 on create | authorization |\n| staff can create but not delete products | staff user: create product succeeds; staff user: delete product | fails with 403 on delete | authorization |\n\n---\n\n#### tests/Feature/Admin/OrderManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with status filter | 3 pending, 2 paid orders; filter by status=paid | 2 results | happy path |\n| shows order detail page | GET /admin/orders/{order} | sees order number, line items, totals | happy path |\n| creates a fulfillment from order detail | Livewire action: select lines, submit fulfillment | fulfillment created | happy path |\n| processes a refund from order detail | Livewire action: enter amount, submit refund | refund created | happy path |\n| restricts order management by role | support user can view orders (read-only); support user cannot update or fulfill | appropriate 403 responses | authorization |\n\n---\n\n#### tests/Feature/Admin/DiscountManagementTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists discounts | create 3 discounts; GET /admin/discounts | sees all 3 | happy path |\n| creates a percent discount | Livewire: code=\"SAVE10\", type=percent, value=10 | discount in DB | happy path |\n| creates a fixed discount | Livewire: code=\"5OFF\", type=fixed, value=500 | discount in DB | happy path |\n| validates discount code uniqueness within store | create discount with code \"SAVE10\"; attempt to create another with same code | validation error | validation |\n| edits a discount | change value from 10 to 15 | updated in DB | happy path |\n| disables a discount | set status to disabled | status = disabled | happy path |\n\n---\n\n#### tests/Feature/Admin/SettingsTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| renders the settings page | GET /admin/settings | 200 response, sees settings tabs | happy path |\n| updates general store settings | update store name | store_settings updated | happy path |\n| configures shipping zones | create zone with countries and rates | shipping_zone and shipping_rates in DB | happy path |\n| configures tax settings | set mode=manual, rate=1900, prices_include_tax=true | tax_settings updated | happy path |\n| restricts settings to owner and admin roles | staff user GET /admin/settings | 403 response | authorization |\n| manages store domains | add a new domain | store_domain created | happy path |\n\n---\n\n#### tests/Feature/Search/SearchTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns products matching search query | products: \"Blue Cotton T-Shirt\", \"Red Wool Sweater\"; search \"cotton\" | finds \"Blue Cotton T-Shirt\" | happy path |\n| scopes search to current store | store A has \"T-Shirt\", store B has \"T-Shirt Deluxe\"; search \"t-shirt\" on store A | only store A product returned | authorization |\n| returns empty for no matches | search \"xyznonexistent\" | empty results | edge case |\n| logs search query for analytics | perform search | search_queries row created | happy path |\n| paginates search results | 25 matching products, 12 per page | page 1 has 12, page 2 has 12, page 3 has 1 | happy path |\n\n---\n\n#### tests/Feature/Search/AutocompleteTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| returns suggestions matching prefix | products: \"Summer Dress\", \"Summer Hat\", \"Winter Coat\"; autocomplete prefix \"sum\" | \"Summer Dress\" and \"Summer Hat\" returned | happy path |\n| limits results to configured count | 20 matching products, limit 5 | 5 results returned | happy path |\n| returns empty for very short prefix | autocomplete prefix \"a\" (below minimum) | empty or all results | edge case |\n\n---\n\n#### tests/Feature/Analytics/EventIngestionTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| tracks a page view event | POST analytics event: type=page_view | analytics_events row created | happy path |\n| tracks an add to cart event | POST event: type=add_to_cart, properties include product_id | event stored with properties | happy path |\n| scopes events to current store | track event on store A | event.store_id = store A id | happy path |\n| includes session ID when available | track event with session | session_id stored | happy path |\n| includes customer ID when authenticated | track event as logged-in customer | customer_id stored | happy path |\n\n---\n\n#### tests/Feature/Analytics/AggregationTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| aggregates daily metrics from raw events | create events: 5 page_views, 3 add_to_carts, 2 checkout_completed; run AggregateAnalytics job | analytics_daily row with correct counts | happy path |\n| calculates revenue and AOV correctly | 3 orders: 1000, 2000, 3000 cents; aggregate | revenue=6000, aov=2000 | happy path |\n| runs idempotently | run aggregation twice for same date | analytics_daily row has correct values (not doubled) | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookDeliveryTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| delivers a webhook to a subscribed URL | mock HTTP client; subscription for event_type \"order.created\"; dispatch event | HTTP POST made to target_url | happy path |\n| signs the payload with HMAC | deliver webhook | `X-Platform-Signature` header present with valid signature | happy path |\n| retries failed deliveries with exponential backoff | first delivery returns 500 | job retried, attempt_count incremented | edge case |\n| marks delivery as failed after max retries | 6 total attempts exhausted (1 initial + 5 retries) | delivery status = failed | edge case |\n| pauses subscription after circuit breaker threshold | 5 consecutive failures | subscription status = paused | edge case |\n\n---\n\n#### tests/Feature/Webhooks/WebhookSignatureTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| generates a valid HMAC-SHA256 signature | payload: '{\"event\":\"order.created\"}'; secret: 'test-secret' | signature matches expected hash | happy path |\n| verifies a valid signature | generate signature, then verify | returns true | happy path |\n| rejects a tampered payload | sign original, verify with modified payload | returns false | validation |\n| rejects an incorrect secret | sign with secret A, verify with secret B | returns false | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCartApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a cart | POST /api/storefront/v1/carts | 201 response, body has cart id, version 1 | happy path |\n| retrieves a cart with lines and totals | create cart with 2 lines; GET /api/storefront/v1/carts/{id} | 200 response, lines array has 2 items, totals calculated | happy path |\n| adds a line to the cart | POST /api/storefront/v1/carts/{id}/lines | 200 response, line added | happy path |\n| updates a line quantity | PUT /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, quantity updated | happy path |\n| removes a line | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |\n| validates variant exists on add | add nonexistent variant_id | 422 response | validation |\n| validates quantity is positive | add with quantity=0 | 422 response | validation |\n| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/999 | 404 response | validation |\n\n---\n\n#### tests/Feature/Api/StorefrontCheckoutApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| creates a checkout from a cart | POST /api/storefront/v1/checkouts with cart_id | 201 response, checkout status=started | happy path |\n| sets checkout address | PUT /api/storefront/v1/checkouts/{id}/address | 200 response, status=addressed | happy path |\n| selects a shipping method | PUT /api/storefront/v1/checkouts/{id}/shipping-method | 200 response, status=shipping_selected, available rates returned in response | happy path |\n| applies a discount code | POST /api/storefront/v1/checkouts/{id}/apply-discount with code | 200 response, discount applied in totals | happy path |\n| retrieves checkout with totals | GET /api/storefront/v1/checkouts/{id} | 200 response, includes totals_json fields | happy path |\n| selects a payment method | PUT /api/storefront/v1/checkouts/{id}/payment-method with method=credit_card | 200 response, status=payment_selected, payment_method stored | happy path |\n| completes checkout with credit card payment | POST /api/storefront/v1/checkouts/{id}/pay with card fields | 200 response, status=completed, order created | happy path |\n| rejects payment with declined card | POST /api/storefront/v1/checkouts/{id}/pay with magic decline number | 422 response, payment declined message | validation |\n| validates required address fields | PUT address with missing city | 422 response | validation |\n\n---\n\n#### tests/Feature/Api/AdminProductApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |\n| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |\n| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |\n| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |\n| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |\n| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |\n| paginates results | 25 products, default page size 15 | first page has 15 items, meta has total=25 | happy path |\n\n---\n\n#### tests/Feature/Api/AdminOrderApiTest.php\n\n| Test Name | Setup / Preconditions | Key Assertions | Category |\n|-----------|----------------------|----------------|----------|\n| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |\n| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |\n| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |\n| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |\n| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |\n| requires write-orders ability for mutations | token with only read-orders; POST fulfillment | 403 response | authorization |\n\n---\n\n## 4. Files to Create Inventory\n\n### Models (with migration, factory, seeder)\n\n| Name | Purpose |\n|------|---------|\n| `Organization` | Top-level tenant entity |\n| `Store` | Individual store within an organization |\n| `StoreDomain` | Domain mapping for a store |\n| `StoreUser` | Pivot model with role attribute |\n| `StoreSettings` | Per-store configuration |\n| `Product` | Product entity |\n| `ProductOption` | Product option definition |\n| `ProductOptionValue` | Option value entry |\n| `ProductVariant` | Product variant (SKU-level) |\n| `InventoryItem` | Inventory tracking per variant |\n| `Collection` | Product collection/category |\n| `ProductMedia` | Product images and videos |\n| `Theme` | Store theme entity |\n| `ThemeFile` | Individual theme file |\n| `ThemeSettings` | Theme configuration |\n| `Page` | CMS page |\n| `NavigationMenu` | Navigation menu entity |\n| `NavigationItem` | Individual navigation item |\n| `Cart` | Shopping cart entity |\n| `CartLine` | Individual cart line item |\n| `Checkout` | Checkout session entity |\n| `ShippingZone` | Shipping zone configuration |\n| `ShippingRate` | Shipping rate within a zone |\n| `TaxSettings` | Per-store tax configuration |\n| `Discount` | Discount code or automatic discount |\n| `Customer` | Store customer entity |\n| `CustomerAddress` | Customer address |\n| `Order` | Order entity |\n| `OrderLine` | Order line item |\n| `Payment` | Payment record |\n| `Refund` | Refund record |\n| `Fulfillment` | Fulfillment record |\n| `FulfillmentLine` | Fulfillment line item |\n| `App` | Third-party app entity |\n| `AppInstallation` | App installation per store |\n| `OauthClient` | OAuth client for app |\n| `OauthToken` | OAuth token for installation |\n| `WebhookSubscription` | Webhook subscription per store |\n| `WebhookDelivery` | Webhook delivery log |\n| `AnalyticsEvent` | Raw analytics event |\n| `AnalyticsDaily` | Pre-aggregated daily analytics |\n| `SearchSettings` | Per-store search configuration |\n| `SearchQuery` | Search query log |\n\n### Livewire Components - Admin\n\n| Name | Purpose |\n|------|---------|\n| `Admin/Auth/Login` | Admin login form |\n| `Admin/Dashboard` | Admin dashboard |\n| `Admin/Layout/Sidebar` | Admin sidebar navigation |\n| `Admin/Layout/TopBar` | Admin top bar with store selector |\n| `Admin/Products/Index` | Product list |\n| `Admin/Products/Form` | Shared product form (create/edit mode) |\n| `Admin/Collections/Index` | Collection list |\n| `Admin/Collections/Form` | Shared collection form (create/edit mode) |\n| `Admin/Orders/Index` | Order list |\n| `Admin/Orders/Show` | Order detail |\n| `Admin/Customers/Index` | Customer list |\n| `Admin/Customers/Show` | Customer detail |\n| `Admin/Discounts/Index` | Discount list |\n| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |\n| `Admin/Settings/Index` | Settings tabs |\n| `Admin/Settings/Shipping` | Shipping zone/rate management |\n| `Admin/Settings/Taxes` | Tax settings |\n| `Admin/Inventory/Index` | Inventory management - list and filter inventory items, adjust quantities (Spec 03 Section 8) |\n| `Admin/Themes/Index` | Theme list |\n| `Admin/Themes/Editor` | Theme editor |\n| `Admin/Pages/Index` | Page list |\n| `Admin/Pages/Form` | Shared page form (create/edit mode) |\n| `Admin/Navigation/Index` | Navigation menu management |\n| `Admin/Analytics/Index` | Analytics dashboard |\n| `Admin/Search/Settings` | Search settings |\n| `Admin/Apps/Index` | App directory |\n| `Admin/Apps/Show` | Installed app detail |\n| `Admin/Developers/Index` | API token and webhook management |\n\n### Livewire Components - Storefront\n\n| Name | Purpose |\n|------|---------|\n| `Storefront/Home` | Storefront home page |\n| `Storefront/Collections/Index` | All collections grid |\n| `Storefront/Collections/Show` | Collection product grid |\n| `Storefront/Products/Show` | Product detail page |\n| `Storefront/Cart/Show` | Full cart page |\n| `Storefront/CartDrawer` | Slide-out cart drawer |\n| `Storefront/Search/Index` | Search results page |\n| `Storefront/Search/Modal` | Search autocomplete modal |\n| `Storefront/Pages/Show` | CMS page renderer |\n| `Storefront/Checkout/Show` | Multi-step checkout |\n| `Storefront/Checkout/Confirmation` | Order confirmation |\n| `Storefront/Account/Auth/Login` | Customer login |\n| `Storefront/Account/Auth/Register` | Customer registration |\n| `Storefront/Account/Dashboard` | Customer dashboard |\n| `Storefront/Account/Orders/Index` | Customer order history |\n| `Storefront/Account/Orders/Show` | Customer order detail |\n| `Storefront/Account/Addresses/Index` | Customer address management |\n\n### Middleware\n\n| Name | Purpose |\n|------|---------|\n| `ResolveStore` | Resolves current store from hostname or session |\n| `CheckStoreRole` | Validates user has required store role |\n| `CustomerAuthenticate` | Customer guard authentication |\n\n### Policies\n\n| Name | Model |\n|------|-------|\n| `ProductPolicy` | Product |\n| `OrderPolicy` | Order |\n| `CollectionPolicy` | Collection |\n| `DiscountPolicy` | Discount |\n| `CustomerPolicy` | Customer |\n| `StorePolicy` | Store |\n| `PagePolicy` | Page |\n| `ThemePolicy` | Theme |\n| `FulfillmentPolicy` | Fulfillment |\n| `RefundPolicy` | Refund |\n\n### Form Requests\n\n| Name | Purpose |\n|------|---------|\n| `StoreProductRequest` | Validate product creation |\n| `UpdateProductRequest` | Validate product update |\n| `StoreCollectionRequest` | Validate collection creation |\n| `UpdateCollectionRequest` | Validate collection update |\n| `StoreDiscountRequest` | Validate discount creation |\n| `UpdateDiscountRequest` | Validate discount update |\n| `SetCheckoutAddressRequest` | Validate checkout address |\n| `SetCheckoutShippingRequest` | Validate checkout shipping selection |\n| `CreateFulfillmentRequest` | Validate fulfillment creation |\n| `CreateRefundRequest` | Validate refund creation |\n| `StorePageRequest` | Validate page creation |\n| `UpdatePageRequest` | Validate page update |\n| `RegisterCustomerRequest` | Validate customer registration |\n| `StoreShippingZoneRequest` | Validate shipping zone creation |\n| `StoreShippingRateRequest` | Validate shipping rate creation |\n| `UpdateTaxSettingsRequest` | Validate tax settings update |\n| `StoreCustomerAddressRequest` | Validate customer address creation |\n| `UpdateCustomerAddressRequest` | Validate customer address update |\n\n### Jobs\n\n| Name | Purpose |\n|------|---------|\n| `ProcessMediaUpload` | Resize images, update media status |\n| `DeliverWebhook` | HTTP POST webhook with retry logic |\n| `AggregateAnalytics` | Daily analytics aggregation |\n| `CleanupAbandonedCarts` | Mark old active carts as abandoned |\n| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |\n\n### Events\n\n| Name | Purpose |\n|------|---------|\n| `OrderCreated` | Dispatched when order is created |\n| `OrderPaid` | Dispatched when payment is captured |\n| `OrderFulfilled` | Dispatched when all lines fulfilled |\n| `OrderCancelled` | Dispatched when order is cancelled |\n| `OrderRefunded` | Dispatched when refund is processed |\n| `CheckoutCompleted` | Dispatched when checkout completes |\n| `CheckoutAddressed` | Dispatched when address is set |\n| `CheckoutShippingSelected` | Dispatched when shipping is selected |\n| `ProductStatusChanged` | Dispatched when product status transitions |\n| `CartUpdated` | Dispatched when cart is mutated |\n\n### Tests\n\n**Unit tests to create (6 files):**\n\n| Name | Purpose |\n|------|---------|\n| `PricingEngineTest` | Pricing pipeline unit tests |\n| `DiscountCalculatorTest` | Discount validation and calculation unit tests |\n| `TaxCalculatorTest` | Tax calculation unit tests |\n| `ShippingCalculatorTest` | Shipping rate calculation unit tests |\n| `CartVersionTest` | Cart versioning unit tests |\n| `HandleGeneratorTest` | Slug generation unit tests |\n\n**Feature tests to create (28 files):**\n\n| Name | Purpose |\n|------|---------|\n| `Tenancy/TenantResolutionTest` | Tenant resolution feature tests |\n| `Tenancy/StoreIsolationTest` | Store isolation feature tests |\n| `Auth/AdminAuthTest` | Admin authentication feature tests |\n| `Auth/CustomerAuthTest` | Customer authentication feature tests |\n| `Auth/SanctumTokenTest` | API token feature tests |\n| `Products/ProductCrudTest` | Product CRUD feature tests |\n| `Products/VariantTest` | Variant management feature tests |\n| `Products/InventoryTest` | Inventory management feature tests |\n| `Products/CollectionTest` | Collection feature tests |\n| `Products/MediaUploadTest` | Media upload feature tests |\n| `Cart/CartServiceTest` | Cart service feature tests |\n| `Cart/CartApiTest` | Cart API feature tests |\n| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |\n| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |\n| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |\n| `Checkout/DiscountTest` | Checkout discount feature tests |\n| `Checkout/ShippingTest` | Checkout shipping feature tests |\n| `Checkout/TaxTest` | Checkout tax feature tests |\n| `Orders/OrderCreationTest` | Order creation feature tests |\n| `Orders/RefundTest` | Refund feature tests |\n| `Orders/FulfillmentTest` | Fulfillment feature tests |\n| `Payments/MockPaymentProviderTest` | Mock PSP charge/decline feature tests |\n| `Payments/PaymentServiceTest` | Payment service feature tests |\n| `Payments/BankTransferConfirmationTest` | Bank transfer confirmation and auto-cancel tests |\n| `Customers/CustomerAccountTest` | Customer account feature tests |\n| `Customers/AddressManagementTest` | Address management feature tests |\n| `Admin/DashboardTest` | Admin dashboard feature tests |\n| `Admin/ProductManagementTest` | Admin product management feature tests |\n| `Admin/OrderManagementTest` | Admin order management feature tests |\n| `Admin/DiscountManagementTest` | Admin discount management feature tests |\n| `Admin/SettingsTest` | Admin settings feature tests |\n| `Search/SearchTest` | Search feature tests |\n| `Search/AutocompleteTest` | Autocomplete feature tests |\n| `Analytics/EventIngestionTest` | Analytics event ingestion feature tests |\n| `Analytics/AggregationTest` | Analytics aggregation feature tests |\n| `Webhooks/WebhookDeliveryTest` | Webhook delivery feature tests |\n| `Webhooks/WebhookSignatureTest` | Webhook signature feature tests |\n| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |\n| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |\n| `Api/AdminProductApiTest` | Admin product API feature tests |\n| `Api/AdminOrderApiTest` | Admin order API feature tests |\n\n---\n\n## 5. Config File Changes\n\n| Config File | Change | Value | Purpose |\n|-------------|--------|-------|---------|\n| `config/database.php` | SQLite pragmas | WAL, foreign keys, busy_timeout 5000, synchronous normal | Optimized SQLite configuration |\n| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |\n| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |\n| `config/cache.php` | Driver | File driver | Cache configuration |\n| `config/queue.php` | Driver | Sync driver | Queue configuration |\n| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |\n| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |\n\n### bootstrap/app.php Changes\n\n- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`\n- Register middleware groups for `storefront` and `admin` route groups\n- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)\n- Configure exception handling\n\n### Routes Files\n\n| File | Contents |\n|------|----------|\n| `routes/web.php` | Admin auth routes, admin routes (prefix `/admin`), storefront routes, checkout routes, customer account routes |\n| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |\n| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily, `CancelUnpaidBankTransferOrders` daily |\n\n### Service Providers\n\n**AppServiceProvider::boot() responsibilities:**\n- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)\n- Register rate limiters\n- Register global scopes\n- Register model observers (ProductObserver for FTS5 sync)\n\n### Services Directory\n\n| Service | Responsibility |\n|---------|---------------|\n| `CartService` | Cart CRUD, line management, version control, session binding |\n| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |\n| `PricingEngine` | Deterministic pricing pipeline: subtotal -> discount -> shipping -> tax -> total |\n| `DiscountService` | Discount validation, amount calculation, line allocation |\n| `ShippingCalculator` | Zone matching, rate calculation |\n| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |\n| `InventoryService` | Reserve, release, commit, restock operations |\n| `ProductService` | Product CRUD, status transitions |\n| `VariantMatrixService` | Cartesian product generation, variant lifecycle |\n| `OrderService` | Order creation from checkout, numbering, inventory commit |\n| `RefundService` | Refund creation, provider API, inventory restock |\n| `FulfillmentService` | Fulfillment CRUD, tracking, status management |\n| `NavigationService` | Menu tree building, URL resolution, caching |\n| `SearchService` | FTS5 index management, query execution, autocomplete |\n| `AnalyticsService` | Event tracking, daily aggregation |\n| `WebhookService` | Event dispatch, HMAC signing, delivery management |\n| `Payments\\MockPaymentProvider` | In-process mock PSP: credit card (magic numbers), PayPal, bank transfer |\n\n---\n\n## 6. File Count Estimate\n\n| Category | Estimated Files |\n|----------|-----------------|\n| Migrations | 28-32 |\n| Models | 32-36 |\n| Livewire Components (Admin) | 28-30 |\n| Livewire Components (Storefront) | 15-17 |\n| Blade Views (Admin) | 28-30 |\n| Blade Views (Storefront) | 18-22 |\n| Blade Components (Storefront) | 8-10 |\n| Services | 14-16 |\n| Policies | 10 |\n| Form Requests | 18-20 |\n| Jobs | 5 |\n| Events | 10 |\n| Middleware | 3 |\n| Enums | 18-20 |\n| Factories | 25-28 |\n| Seeders | 20-22 |\n| Config changes | 6-7 |\n| Routes | 3 |\n| Value Objects | 3-4 |\n| Contracts/Interfaces | 2-3 |\n| Traits/Concerns | 2-3 |\n| Scopes | 1 |\n| Observers | 1 |\n| Support classes | 2-3 |\n| Tests (Unit) | 6 |\n| Tests (Feature) | 28-30 |\n| Tests (Browser) | 18 files (143 tests) |\n| CSS/JS | 3-5 |\n| **Total** | **~320-380 files** |\n\n---\n\n## 7. Verification Checklist\n\n1. Install Composer dependencies and confirm no errors\n2. Install npm dependencies and run the Vite build; confirm it succeeds\n3. Run a fresh migration on a clean SQLite database and confirm all migrations complete without errors\n4. Run all seeders and confirm demo data is created without errors\n5. Run all unit tests and confirm they pass\n6. Run all feature tests and confirm they pass\n7. Run all browser tests and confirm they pass (requires browser driver)\n8. Run the code style formatter/checker and confirm code conforms to project standards\n9. Manual smoke: visit the storefront domain and confirm it renders with products\n10. Manual smoke: visit the admin login page and confirm it renders; authenticate as the demo admin user\n11. Manual smoke: add a product to the cart, proceed through the full checkout flow\n12. Manual smoke: in admin, create a product, manage an order, and process a fulfillment\n13. List all registered routes and confirm they are correct with no duplicates\n14. Run the config cache command and confirm it works (verifying no `env()` calls outside config files)\n\n---\n\n## 8. Seeder Execution Order\n\nSeeders must run in dependency order. The `DatabaseSeeder` should call them sequentially in this order:\n\n1. `OrganizationSeeder`\n2. `StoreSeeder`\n3. `StoreDomainSeeder`\n4. `UserSeeder`\n5. `StoreUserSeeder`\n6. `StoreSettingsSeeder`\n7. `TaxSettingsSeeder`\n8. `ShippingSeeder` - creates shipping zones and rates\n9. `CollectionSeeder` - creates collections (before products so products can be linked)\n10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations\n11. `DiscountSeeder`\n12. `CustomerSeeder` - creates customers and their addresses\n13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments\n14. `ThemeSeeder` - creates themes with files and settings\n15. `PageSeeder`\n16. `NavigationSeeder` - creates navigation menus and items\n17. `AnalyticsSeeder`\n18. `SearchSettingsSeeder`\n\nSee spec `07-SEEDERS-AND-TEST-DATA.md` for detailed seeder data definitions.\n","structuredPatch":[{"oldStart":1316,"oldLines":7,"newStart":1316,"newLines":7,"lines":[" | creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |"," | completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |"," | rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |","-| expires checkout after timeout | checkout with payment_pending status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |","+| expires checkout after timeout | checkout with payment_selected status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |"," | prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |"," "," ---"]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"42d6df22-06bb-4dfd-b07c-0f9c400d4be5"}
{"parentUuid":"e96b8f28-25f3-4c2f-975c-663631c1a4b1","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Baskrf9m6Ac68fP96E44xz","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me verify no `payment_pending` remains outside the check report itself."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1552,"cache_read_input_tokens":71348,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1552},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YTVEYggQcDYGE8vCgY","type":"assistant","uuid":"3bc3677a-3586-41ab-97bf-f4c3277bf163","timestamp":"2026-02-11T15:47:21.240Z"}
{"parentUuid":"3bc3677a-3586-41ab-97bf-f4c3277bf163","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Baskrf9m6Ac68fP96E44xz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WRvTdqp9XWm3v7cYLMi1aj","name":"Grep","input":{"pattern":"payment_pending","path":"/Users/fabianwesner/Herd/shop/specs","output_mode":"files_with_matches"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1552,"cache_read_input_tokens":71348,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1552},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YTVEYggQcDYGE8vCgY","type":"assistant","uuid":"62e3d8c1-2842-465a-9804-745cff6d309a","timestamp":"2026-02-11T15:47:22.159Z"}
{"parentUuid":"62e3d8c1-2842-465a-9804-745cff6d309a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_01WRvTdqp9XWm3v7cYLMi1aj","toolUseID":"toolu_01WRvTdqp9XWm3v7cYLMi1aj","timestamp":"2026-02-11T15:47:22.450Z","uuid":"0a0e1e02-a6d4-427f-b082-0fd78e3997ba"}
{"parentUuid":"62e3d8c1-2842-465a-9804-745cff6d309a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WRvTdqp9XWm3v7cYLMi1aj","type":"tool_result","content":"Found 1 file\nspecs/codex-spec-check-04.md"}]},"uuid":"cbc257cb-5936-4e5f-9870-f4371a69b5c7","timestamp":"2026-02-11T15:47:22.449Z","toolUseResult":{"mode":"files_with_matches","filenames":["specs/codex-spec-check-04.md"],"numFiles":1},"sourceToolAssistantUUID":"62e3d8c1-2842-465a-9804-745cff6d309a"}
{"parentUuid":"cbc257cb-5936-4e5f-9870-f4371a69b5c7","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Luu5hjX5dbVc4vJfmduYrj","type":"message","role":"assistant","content":[{"type":"text","text":"Only the check report itself still references `payment_pending` (as evidence text). All spec files are clean. Now let me also verify `payment_selected` is consistent in spec 02 (which was already updated in the prior session)."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":157,"cache_read_input_tokens":72900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":157},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YToWPgkZGkjjtXgqdJ","type":"assistant","uuid":"1d1d9ae8-4d78-4d50-96eb-919e14149e0d","timestamp":"2026-02-11T15:47:26.771Z"}
{"parentUuid":"1d1d9ae8-4d78-4d50-96eb-919e14149e0d","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01Luu5hjX5dbVc4vJfmduYrj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LqifynqR6JGK5PKHNs1MZz","name":"Grep","input":{"pattern":"payment_selected","path":"/Users/fabianwesner/Herd/shop/specs","output_mode":"files_with_matches"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":157,"cache_read_input_tokens":72900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":157},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YToWPgkZGkjjtXgqdJ","type":"assistant","uuid":"486473c6-e74d-427f-92a7-49edba448491","timestamp":"2026-02-11T15:47:27.288Z"}
{"parentUuid":"486473c6-e74d-427f-92a7-49edba448491","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Grep","command":"callback"},"parentToolUseID":"toolu_01LqifynqR6JGK5PKHNs1MZz","toolUseID":"toolu_01LqifynqR6JGK5PKHNs1MZz","timestamp":"2026-02-11T15:47:27.561Z","uuid":"77c7b31b-e71b-43a4-b893-b88f42ca297e"}
{"parentUuid":"486473c6-e74d-427f-92a7-49edba448491","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LqifynqR6JGK5PKHNs1MZz","type":"tool_result","content":"Found 5 files\nspecs/09-IMPLEMENTATION-ROADMAP.md\nspecs/05-BUSINESS-LOGIC.md\nspecs/02-API-ROUTES.md\nspecs/01-DATABASE-SCHEMA.md\nspecs/codex-spec-check-04.md"}]},"uuid":"77b9eed5-76b9-4a5d-b480-feca705ac12a","timestamp":"2026-02-11T15:47:27.560Z","toolUseResult":{"mode":"files_with_matches","filenames":["specs/09-IMPLEMENTATION-ROADMAP.md","specs/05-BUSINESS-LOGIC.md","specs/02-API-ROUTES.md","specs/01-DATABASE-SCHEMA.md","specs/codex-spec-check-04.md"],"numFiles":5},"sourceToolAssistantUUID":"486473c6-e74d-427f-92a7-49edba448491"}
{"parentUuid":"77b9eed5-76b9-4a5d-b480-feca705ac12a","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FGB1htFdbgcu3549W2F9vb","type":"message","role":"assistant","content":[{"type":"text","text":"Good - `payment_selected` is present in all 4 relevant spec files (01, 02, 05, 09). Now let me update the check report itself to reflect the resolved status."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":237,"cache_read_input_tokens":73057,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":237},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YUBKK4gicsCP7SbHEZ","type":"assistant","uuid":"5eed199b-d387-4b2e-8942-fd4edfc2740e","timestamp":"2026-02-11T15:47:31.096Z"}
{"parentUuid":"5eed199b-d387-4b2e-8942-fd4edfc2740e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01FGB1htFdbgcu3549W2F9vb","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015KaU8pLQPPWS4p1vqQrBU5","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":237,"cache_read_input_tokens":73057,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":237},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YUBKK4gicsCP7SbHEZ","type":"assistant","uuid":"bb273ef3-fed3-45af-b447-b029acde72a6","timestamp":"2026-02-11T15:47:31.502Z"}
{"parentUuid":"bb273ef3-fed3-45af-b447-b029acde72a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_015KaU8pLQPPWS4p1vqQrBU5","toolUseID":"toolu_015KaU8pLQPPWS4p1vqQrBU5","timestamp":"2026-02-11T15:47:31.508Z","uuid":"d7d7fb85-7fbf-437a-ab52-84b07a3b2b5b"}
{"parentUuid":"bb273ef3-fed3-45af-b447-b029acde72a6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015KaU8pLQPPWS4p1vqQrBU5","type":"tool_result","content":"     1→# Codex Spec Consistency Check (04)\n     2→\n     3→Date: 2026-02-11\n     4→\n     5→Scope reviewed:\n     6→- `specs/01-DATABASE-SCHEMA.md`\n     7→- `specs/02-API-ROUTES.md`\n     8→- `specs/03-ADMIN-UI.md`\n     9→- `specs/04-STOREFRONT-UI.md`\n    10→- `specs/05-BUSINESS-LOGIC.md`\n    11→- `specs/06-AUTH-AND-SECURITY.md`\n    12→- `specs/07-SEEDERS-AND-TEST-DATA.md`\n    13→- `specs/08-PLAYWRIGHT-E2E-PLAN.md`\n    14→- `specs/09-IMPLEMENTATION-ROADMAP.md`\n    15→\n    16→## Executive Summary\n    17→\n    18→The Mock PSP migration is mostly integrated and many prior blockers are fixed. Remaining inconsistencies are now concentrated in checkout/payment state modeling, payment status enums, and a few stale roadmap/API/test references.\n    19→\n    20→## What Improved Since Report 03\n    21→\n    22→- Discount contract is now aligned across schema/API/security:\n    23→  - `specs/01-DATABASE-SCHEMA.md:680`\n    24→  - `specs/02-API-ROUTES.md:2221`\n    25→  - `specs/06-AUTH-AND-SECURITY.md:790`\n    26→- Analytics dedupe key is aligned to store-scoped `client_event_id`:\n    27→  - `specs/01-DATABASE-SCHEMA.md:926`\n    28→  - `specs/02-API-ROUTES.md:1188`\n    29→  - `specs/05-BUSINESS-LOGIC.md:1466`\n    30→- API ability vocabulary alignment improved (`read-settings`, `write-settings`, `write-themes`, `read-content`, `write-content`, `manage-platform` now present in security spec):\n    31→  - `specs/06-AUTH-AND-SECURITY.md:257`\n    32→  - `specs/06-AUTH-AND-SECURITY.md:258`\n    33→  - `specs/06-AUTH-AND-SECURITY.md:260`\n    34→  - `specs/06-AUTH-AND-SECURITY.md:261`\n    35→  - `specs/06-AUTH-AND-SECURITY.md:262`\n    36→  - `specs/06-AUTH-AND-SECURITY.md:263`\n    37→- Inventory is now represented in roadmap admin component inventory:\n    38→  - `specs/09-IMPLEMENTATION-ROADMAP.md:1759`\n    39→\n    40→## Findings (Ordered by Severity)\n    41→\n    42→### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)\n    43→\n    44→Evidence:\n    45→- API routes moved to `payment_selected`:\n    46→  - `specs/02-API-ROUTES.md:742`\n    47→  - `specs/02-API-ROUTES.md:3143`\n    48→  - `specs/02-API-ROUTES.md:3160`\n    49→- Schema still allows `payment_pending` (and not `payment_selected`):\n    50→  - `specs/01-DATABASE-SCHEMA.md:587`\n    51→- Business logic still models `payment_pending` transitions:\n    52→  - `specs/05-BUSINESS-LOGIC.md:415`\n    53→  - `specs/05-BUSINESS-LOGIC.md:487`\n    54→- Roadmap still models/testing `payment_pending`:\n    55→  - `specs/09-IMPLEMENTATION-ROADMAP.md:575`\n    56→  - `specs/09-IMPLEMENTATION-ROADMAP.md:1333`\n    57→\n    58→Impact:\n    59→- Implementations cannot satisfy all documents simultaneously without choosing one state model.\n    60→\n    61→### 2) Critical: Payment status enum is inconsistent after Mock PSP migration\n    62→\n    63→Evidence:\n    64→- Schema `payments.status` still uses legacy values and default:\n    65→  - `specs/01-DATABASE-SCHEMA.md:829` (`requires_action`, `authorized`, `captured`, `failed`, `refunded`)\n    66→- Business logic and roadmap define `PaymentStatus` as `pending|captured|failed|refunded`:\n    67→  - `specs/05-BUSINESS-LOGIC.md:1002`\n    68→  - `specs/09-IMPLEMENTATION-ROADMAP.md:650`\n    69→- Seeders rely on `pending` payment status:\n    70→  - `specs/07-SEEDERS-AND-TEST-DATA.md:438`\n    71→  - `specs/07-SEEDERS-AND-TEST-DATA.md:1618`\n    72→\n    73→Impact:\n    74→- Schema constraints conflict with seeded/tested runtime states.\n    75→\n    76→### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints\n    77→\n    78→Evidence:\n    79→- API routes define:\n    80→  - `PUT /checkouts/{id}/payment-method`: `specs/02-API-ROUTES.md:721`\n    81→  - `POST /checkouts/{id}/pay`: `specs/02-API-ROUTES.md:848`\n    82→- Roadmap storefront API tests do not include either endpoint:\n    83→  - `specs/09-IMPLEMENTATION-ROADMAP.md:1652`\n    84→  - `specs/09-IMPLEMENTATION-ROADMAP.md:1653`\n    85→  - `specs/09-IMPLEMENTATION-ROADMAP.md:1654`\n    86→  - `specs/09-IMPLEMENTATION-ROADMAP.md:1655`\n    87→  - `specs/09-IMPLEMENTATION-ROADMAP.md:1656`\n    88→\n    89→Impact:\n    90→- Core payment API behavior introduced in `02` is not represented in roadmap acceptance tests.\n    91→\n    92→### 4) High: Intra-file inconsistency in `02` payment state validation text\n    93→\n    94→Evidence:\n    95→- `pay` endpoint error text still references `payment_pending`:\n    96→  - `specs/02-API-ROUTES.md:936`\n    97→- Same document state machine uses `payment_selected`:\n    98→  - `specs/02-API-ROUTES.md:3143`\n    99→  - `specs/02-API-ROUTES.md:3160`\n   100→\n   101→Impact:\n   102→- Contradictory validation guidance in the same spec section.\n   103→\n   104→### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job\n   105→\n   106→Evidence:\n   107→- Job is defined as required daily:\n   108→  - `specs/09-IMPLEMENTATION-ROADMAP.md:686`\n   109→- Consolidated `routes/console.php` schedule list omits it:\n   110→  - `specs/09-IMPLEMENTATION-ROADMAP.md:1950`\n   111→\n   112→Impact:\n   113→- Implementation checklist can miss a critical operational job.\n   114→\n   115→### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes\n   116→\n   117→Evidence:\n   118→- Endpoint mapping table still lists:\n   119→  - `specs/02-API-ROUTES.md:3121` (`POST /api/storefront/v1/payments*`)\n   120→- Actual payment endpoints are under `/checkouts/{id}/pay` and `/checkouts/{id}/payment-method`:\n   121→  - `specs/02-API-ROUTES.md:721`\n   122→  - `specs/02-API-ROUTES.md:848`\n   123→\n   124→Impact:\n   125→- Rate-limit mapping and route inventory are misleading.\n   126→\n   127→### 7) Medium: Settings tab scope still differs across route/UI/E2E specs\n   128→\n   129→Evidence:\n   130→- Route description includes `Localization` and `API tokens` as settings tabs:\n   131→  - `specs/02-API-ROUTES.md:92`\n   132→- Admin UI tabs list:\n   133→  - `specs/03-ADMIN-UI.md:1423`\n   134→- E2E settings scope:\n   135→  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:1041`\n   136→\n   137→Impact:\n   138→- Expected settings surface is ambiguous for implementation and testing.\n   139→\n   140→### 8) Medium: Browser test totals are internally inconsistent\n   141→\n   142→Evidence:\n   143→- `08` top summary says 143 tests:\n   144→  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:29`\n   145→- `08` bottom summary still says 134 tests:\n   146→  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3140`\n   147→- Roadmap effort estimate still uses 134:\n   148→  - `specs/09-IMPLEMENTATION-ROADMAP.md:2014`\n   149→\n   150→Impact:\n   151→- Planning and progress validation against test inventory are inconsistent.\n   152→\n   153→### 9) Low: Webhook direction wording drift in security spec\n   154→\n   155→Evidence:\n   156→- Route groups describe webhook routes as outbound-app-webhook reserved (no inbound payment webhooks):\n   157→  - `specs/06-AUTH-AND-SECURITY.md:617`\n   158→- Rate limiter table still says `webhooks` applies to incoming webhook endpoints:\n   159→  - `specs/06-AUTH-AND-SECURITY.md:744`\n   160→\n   161→Impact:\n   162→- Operational intent is slightly ambiguous.\n   163→\n   164→### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow\n   165→\n   166→Evidence:\n   167→- UI copy says: \"You will be redirected to complete your PayPal payment.\"\n   168→  - `specs/04-STOREFRONT-UI.md:1087`\n   169→- API and E2E guidance say mock processing is in-process with no redirects:\n   170→  - `specs/02-API-ROUTES.md:850`\n   171→  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3167`\n   172→\n   173→Impact:\n   174→- UX copy conflicts with intended technical behavior.\n   175→\n   176→## Recommended Final Normalization Pass\n   177→\n   178→1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.\n   179→2. Align `payments.status` schema enum with Mock PSP semantics used in logic/seeders/tests.\n   180→3. Update `09` storefront API test matrix to include `/payment-method` and `/pay`.\n   181→4. Fix `02` pay endpoint 409 state wording and remove orphan `/payments*` mapping row.\n   182→5. Add `CancelUnpaidBankTransferOrders` to the consolidated `routes/console.php` schedule inventory in `09`.\n   183→6. Reconcile settings tab scope across `02`, `03`, and `08`.\n   184→7. Reconcile browser test totals across `08` (top and bottom summaries) and `09`.\n   185→8. Harmonize webhook wording in `06` and PayPal copy in `04` with Mock PSP behavior.\n   186→\n   187→## Readiness\n   188→\n   189→Current readiness for fair one-shot coding-agent comparison: **Medium**.\n   190→\n   191→The payment architecture direction is clear, but contract-level inconsistencies in state/status modeling and test/schedule inventories still create implementation ambiguity.\n   192→"}]},"uuid":"0e6be2cb-7a12-47e8-b0ba-e346190314bc","timestamp":"2026-02-11T15:47:31.507Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md","content":"# Codex Spec Consistency Check (04)\n\nDate: 2026-02-11\n\nScope reviewed:\n- `specs/01-DATABASE-SCHEMA.md`\n- `specs/02-API-ROUTES.md`\n- `specs/03-ADMIN-UI.md`\n- `specs/04-STOREFRONT-UI.md`\n- `specs/05-BUSINESS-LOGIC.md`\n- `specs/06-AUTH-AND-SECURITY.md`\n- `specs/07-SEEDERS-AND-TEST-DATA.md`\n- `specs/08-PLAYWRIGHT-E2E-PLAN.md`\n- `specs/09-IMPLEMENTATION-ROADMAP.md`\n\n## Executive Summary\n\nThe Mock PSP migration is mostly integrated and many prior blockers are fixed. Remaining inconsistencies are now concentrated in checkout/payment state modeling, payment status enums, and a few stale roadmap/API/test references.\n\n## What Improved Since Report 03\n\n- Discount contract is now aligned across schema/API/security:\n  - `specs/01-DATABASE-SCHEMA.md:680`\n  - `specs/02-API-ROUTES.md:2221`\n  - `specs/06-AUTH-AND-SECURITY.md:790`\n- Analytics dedupe key is aligned to store-scoped `client_event_id`:\n  - `specs/01-DATABASE-SCHEMA.md:926`\n  - `specs/02-API-ROUTES.md:1188`\n  - `specs/05-BUSINESS-LOGIC.md:1466`\n- API ability vocabulary alignment improved (`read-settings`, `write-settings`, `write-themes`, `read-content`, `write-content`, `manage-platform` now present in security spec):\n  - `specs/06-AUTH-AND-SECURITY.md:257`\n  - `specs/06-AUTH-AND-SECURITY.md:258`\n  - `specs/06-AUTH-AND-SECURITY.md:260`\n  - `specs/06-AUTH-AND-SECURITY.md:261`\n  - `specs/06-AUTH-AND-SECURITY.md:262`\n  - `specs/06-AUTH-AND-SECURITY.md:263`\n- Inventory is now represented in roadmap admin component inventory:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1759`\n\n## Findings (Ordered by Severity)\n\n### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)\n\nEvidence:\n- API routes moved to `payment_selected`:\n  - `specs/02-API-ROUTES.md:742`\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n- Schema still allows `payment_pending` (and not `payment_selected`):\n  - `specs/01-DATABASE-SCHEMA.md:587`\n- Business logic still models `payment_pending` transitions:\n  - `specs/05-BUSINESS-LOGIC.md:415`\n  - `specs/05-BUSINESS-LOGIC.md:487`\n- Roadmap still models/testing `payment_pending`:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:575`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1333`\n\nImpact:\n- Implementations cannot satisfy all documents simultaneously without choosing one state model.\n\n### 2) Critical: Payment status enum is inconsistent after Mock PSP migration\n\nEvidence:\n- Schema `payments.status` still uses legacy values and default:\n  - `specs/01-DATABASE-SCHEMA.md:829` (`requires_action`, `authorized`, `captured`, `failed`, `refunded`)\n- Business logic and roadmap define `PaymentStatus` as `pending|captured|failed|refunded`:\n  - `specs/05-BUSINESS-LOGIC.md:1002`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:650`\n- Seeders rely on `pending` payment status:\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:438`\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:1618`\n\nImpact:\n- Schema constraints conflict with seeded/tested runtime states.\n\n### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints\n\nEvidence:\n- API routes define:\n  - `PUT /checkouts/{id}/payment-method`: `specs/02-API-ROUTES.md:721`\n  - `POST /checkouts/{id}/pay`: `specs/02-API-ROUTES.md:848`\n- Roadmap storefront API tests do not include either endpoint:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1652`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1653`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1654`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1655`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1656`\n\nImpact:\n- Core payment API behavior introduced in `02` is not represented in roadmap acceptance tests.\n\n### 4) High: Intra-file inconsistency in `02` payment state validation text\n\nEvidence:\n- `pay` endpoint error text still references `payment_pending`:\n  - `specs/02-API-ROUTES.md:936`\n- Same document state machine uses `payment_selected`:\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n\nImpact:\n- Contradictory validation guidance in the same spec section.\n\n### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job\n\nEvidence:\n- Job is defined as required daily:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:686`\n- Consolidated `routes/console.php` schedule list omits it:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1950`\n\nImpact:\n- Implementation checklist can miss a critical operational job.\n\n### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes\n\nEvidence:\n- Endpoint mapping table still lists:\n  - `specs/02-API-ROUTES.md:3121` (`POST /api/storefront/v1/payments*`)\n- Actual payment endpoints are under `/checkouts/{id}/pay` and `/checkouts/{id}/payment-method`:\n  - `specs/02-API-ROUTES.md:721`\n  - `specs/02-API-ROUTES.md:848`\n\nImpact:\n- Rate-limit mapping and route inventory are misleading.\n\n### 7) Medium: Settings tab scope still differs across route/UI/E2E specs\n\nEvidence:\n- Route description includes `Localization` and `API tokens` as settings tabs:\n  - `specs/02-API-ROUTES.md:92`\n- Admin UI tabs list:\n  - `specs/03-ADMIN-UI.md:1423`\n- E2E settings scope:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:1041`\n\nImpact:\n- Expected settings surface is ambiguous for implementation and testing.\n\n### 8) Medium: Browser test totals are internally inconsistent\n\nEvidence:\n- `08` top summary says 143 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:29`\n- `08` bottom summary still says 134 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3140`\n- Roadmap effort estimate still uses 134:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:2014`\n\nImpact:\n- Planning and progress validation against test inventory are inconsistent.\n\n### 9) Low: Webhook direction wording drift in security spec\n\nEvidence:\n- Route groups describe webhook routes as outbound-app-webhook reserved (no inbound payment webhooks):\n  - `specs/06-AUTH-AND-SECURITY.md:617`\n- Rate limiter table still says `webhooks` applies to incoming webhook endpoints:\n  - `specs/06-AUTH-AND-SECURITY.md:744`\n\nImpact:\n- Operational intent is slightly ambiguous.\n\n### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow\n\nEvidence:\n- UI copy says: \"You will be redirected to complete your PayPal payment.\"\n  - `specs/04-STOREFRONT-UI.md:1087`\n- API and E2E guidance say mock processing is in-process with no redirects:\n  - `specs/02-API-ROUTES.md:850`\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3167`\n\nImpact:\n- UX copy conflicts with intended technical behavior.\n\n## Recommended Final Normalization Pass\n\n1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.\n2. Align `payments.status` schema enum with Mock PSP semantics used in logic/seeders/tests.\n3. Update `09` storefront API test matrix to include `/payment-method` and `/pay`.\n4. Fix `02` pay endpoint 409 state wording and remove orphan `/payments*` mapping row.\n5. Add `CancelUnpaidBankTransferOrders` to the consolidated `routes/console.php` schedule inventory in `09`.\n6. Reconcile settings tab scope across `02`, `03`, and `08`.\n7. Reconcile browser test totals across `08` (top and bottom summaries) and `09`.\n8. Harmonize webhook wording in `06` and PayPal copy in `04` with Mock PSP behavior.\n\n## Readiness\n\nCurrent readiness for fair one-shot coding-agent comparison: **Medium**.\n\nThe payment architecture direction is clear, but contract-level inconsistencies in state/status modeling and test/schedule inventories still create implementation ambiguity.\n","numLines":192,"startLine":1,"totalLines":192}},"sourceToolAssistantUUID":"bb273ef3-fed3-45af-b447-b029acde72a6"}
{"parentUuid":"0e6be2cb-7a12-47e8-b0ba-e346190314bc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014TqN1CScrZe38CUX6d7YdY","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me update the check report to reflect all resolved findings."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3184,"cache_read_input_tokens":73294,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3184},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YUUMjtgRUH7M9RYcSE","type":"assistant","uuid":"28781d3b-a925-446c-8086-dc095e1bc1ae","timestamp":"2026-02-11T15:47:34.150Z"}
{"type":"file-history-snapshot","messageId":"04a3794d-0684-43e0-940d-808112475c85","snapshot":{"messageId":"f9164427-c32c-42df-a8b7-996fe529c585","trackedFileBackups":{"specs/01-DATABASE-SCHEMA.md":{"backupFileName":"6c9db8afe4a44483@v2","version":2,"backupTime":"2026-02-11T15:43:11.335Z"},"specs/05-BUSINESS-LOGIC.md":{"backupFileName":"45c14dbe21b10ff6@v2","version":2,"backupTime":"2026-02-11T15:43:11.336Z"},"specs/02-API-ROUTES.md":{"backupFileName":"518b51d210810e2c@v2","version":2,"backupTime":"2026-02-11T15:43:11.336Z"},"specs/03-ADMIN-UI.md":{"backupFileName":"a791bee027d52553@v2","version":2,"backupTime":"2026-02-11T15:43:11.337Z"},"specs/04-STOREFRONT-UI.md":{"backupFileName":"401411061d8a6ac1@v2","version":2,"backupTime":"2026-02-11T15:43:11.337Z"},"specs/07-SEEDERS-AND-TEST-DATA.md":{"backupFileName":"c11f09e3e5d3a009@v2","version":2,"backupTime":"2026-02-11T15:43:11.337Z"},"specs/08-PLAYWRIGHT-E2E-PLAN.md":{"backupFileName":"2744918423fe477c@v2","version":2,"backupTime":"2026-02-11T15:43:11.338Z"},"specs/09-IMPLEMENTATION-ROADMAP.md":{"backupFileName":"1f0335ca0ea0d4c4@v2","version":2,"backupTime":"2026-02-11T15:43:11.338Z"},"specs/06-AUTH-AND-SECURITY.md":{"backupFileName":"a579def47b6be477@v2","version":2,"backupTime":"2026-02-11T15:43:11.339Z"},"specs/codex-spec-check-04.md":{"backupFileName":"815c9be6c731c311@v1","version":1,"backupTime":"2026-02-11T15:47:36.100Z"}},"timestamp":"2026-02-11T15:43:11.334Z"},"isSnapshotUpdate":true}
{"parentUuid":"28781d3b-a925-446c-8086-dc095e1bc1ae","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_014TqN1CScrZe38CUX6d7YdY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UdxuBo9jM19SXC4upLmdgY","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md","old_string":"## Executive Summary\n\nThe Mock PSP migration is mostly integrated and many prior blockers are fixed. Remaining inconsistencies are now concentrated in checkout/payment state modeling, payment status enums, and a few stale roadmap/API/test references.","new_string":"## Executive Summary\n\nAll 10 findings from the original report have been resolved. The Mock PSP migration is now fully integrated and consistent across all spec files."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3184,"cache_read_input_tokens":73294,"output_tokens":213,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":3184,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[]}},"requestId":"req_011CY2YUUMjtgRUH7M9RYcSE","type":"assistant","uuid":"04a3794d-0684-43e0-940d-808112475c85","timestamp":"2026-02-11T15:47:36.096Z"}
{"parentUuid":"04a3794d-0684-43e0-940d-808112475c85","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01UdxuBo9jM19SXC4upLmdgY","toolUseID":"toolu_01UdxuBo9jM19SXC4upLmdgY","timestamp":"2026-02-11T15:47:36.104Z","uuid":"d7d273ba-e7e8-4a79-9d2f-3107dc0ff397"}
{"parentUuid":"04a3794d-0684-43e0-940d-808112475c85","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UdxuBo9jM19SXC4upLmdgY","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md has been updated successfully."}]},"uuid":"edd09079-80bd-4ec5-a314-1acc1c9eb4bc","timestamp":"2026-02-11T15:47:36.104Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md","oldString":"## Executive Summary\n\nThe Mock PSP migration is mostly integrated and many prior blockers are fixed. Remaining inconsistencies are now concentrated in checkout/payment state modeling, payment status enums, and a few stale roadmap/API/test references.","newString":"## Executive Summary\n\nAll 10 findings from the original report have been resolved. The Mock PSP migration is now fully integrated and consistent across all spec files.","originalFile":"# Codex Spec Consistency Check (04)\n\nDate: 2026-02-11\n\nScope reviewed:\n- `specs/01-DATABASE-SCHEMA.md`\n- `specs/02-API-ROUTES.md`\n- `specs/03-ADMIN-UI.md`\n- `specs/04-STOREFRONT-UI.md`\n- `specs/05-BUSINESS-LOGIC.md`\n- `specs/06-AUTH-AND-SECURITY.md`\n- `specs/07-SEEDERS-AND-TEST-DATA.md`\n- `specs/08-PLAYWRIGHT-E2E-PLAN.md`\n- `specs/09-IMPLEMENTATION-ROADMAP.md`\n\n## Executive Summary\n\nThe Mock PSP migration is mostly integrated and many prior blockers are fixed. Remaining inconsistencies are now concentrated in checkout/payment state modeling, payment status enums, and a few stale roadmap/API/test references.\n\n## What Improved Since Report 03\n\n- Discount contract is now aligned across schema/API/security:\n  - `specs/01-DATABASE-SCHEMA.md:680`\n  - `specs/02-API-ROUTES.md:2221`\n  - `specs/06-AUTH-AND-SECURITY.md:790`\n- Analytics dedupe key is aligned to store-scoped `client_event_id`:\n  - `specs/01-DATABASE-SCHEMA.md:926`\n  - `specs/02-API-ROUTES.md:1188`\n  - `specs/05-BUSINESS-LOGIC.md:1466`\n- API ability vocabulary alignment improved (`read-settings`, `write-settings`, `write-themes`, `read-content`, `write-content`, `manage-platform` now present in security spec):\n  - `specs/06-AUTH-AND-SECURITY.md:257`\n  - `specs/06-AUTH-AND-SECURITY.md:258`\n  - `specs/06-AUTH-AND-SECURITY.md:260`\n  - `specs/06-AUTH-AND-SECURITY.md:261`\n  - `specs/06-AUTH-AND-SECURITY.md:262`\n  - `specs/06-AUTH-AND-SECURITY.md:263`\n- Inventory is now represented in roadmap admin component inventory:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1759`\n\n## Findings (Ordered by Severity)\n\n### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)\n\nEvidence:\n- API routes moved to `payment_selected`:\n  - `specs/02-API-ROUTES.md:742`\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n- Schema still allows `payment_pending` (and not `payment_selected`):\n  - `specs/01-DATABASE-SCHEMA.md:587`\n- Business logic still models `payment_pending` transitions:\n  - `specs/05-BUSINESS-LOGIC.md:415`\n  - `specs/05-BUSINESS-LOGIC.md:487`\n- Roadmap still models/testing `payment_pending`:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:575`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1333`\n\nImpact:\n- Implementations cannot satisfy all documents simultaneously without choosing one state model.\n\n### 2) Critical: Payment status enum is inconsistent after Mock PSP migration\n\nEvidence:\n- Schema `payments.status` still uses legacy values and default:\n  - `specs/01-DATABASE-SCHEMA.md:829` (`requires_action`, `authorized`, `captured`, `failed`, `refunded`)\n- Business logic and roadmap define `PaymentStatus` as `pending|captured|failed|refunded`:\n  - `specs/05-BUSINESS-LOGIC.md:1002`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:650`\n- Seeders rely on `pending` payment status:\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:438`\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:1618`\n\nImpact:\n- Schema constraints conflict with seeded/tested runtime states.\n\n### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints\n\nEvidence:\n- API routes define:\n  - `PUT /checkouts/{id}/payment-method`: `specs/02-API-ROUTES.md:721`\n  - `POST /checkouts/{id}/pay`: `specs/02-API-ROUTES.md:848`\n- Roadmap storefront API tests do not include either endpoint:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1652`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1653`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1654`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1655`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1656`\n\nImpact:\n- Core payment API behavior introduced in `02` is not represented in roadmap acceptance tests.\n\n### 4) High: Intra-file inconsistency in `02` payment state validation text\n\nEvidence:\n- `pay` endpoint error text still references `payment_pending`:\n  - `specs/02-API-ROUTES.md:936`\n- Same document state machine uses `payment_selected`:\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n\nImpact:\n- Contradictory validation guidance in the same spec section.\n\n### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job\n\nEvidence:\n- Job is defined as required daily:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:686`\n- Consolidated `routes/console.php` schedule list omits it:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1950`\n\nImpact:\n- Implementation checklist can miss a critical operational job.\n\n### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes\n\nEvidence:\n- Endpoint mapping table still lists:\n  - `specs/02-API-ROUTES.md:3121` (`POST /api/storefront/v1/payments*`)\n- Actual payment endpoints are under `/checkouts/{id}/pay` and `/checkouts/{id}/payment-method`:\n  - `specs/02-API-ROUTES.md:721`\n  - `specs/02-API-ROUTES.md:848`\n\nImpact:\n- Rate-limit mapping and route inventory are misleading.\n\n### 7) Medium: Settings tab scope still differs across route/UI/E2E specs\n\nEvidence:\n- Route description includes `Localization` and `API tokens` as settings tabs:\n  - `specs/02-API-ROUTES.md:92`\n- Admin UI tabs list:\n  - `specs/03-ADMIN-UI.md:1423`\n- E2E settings scope:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:1041`\n\nImpact:\n- Expected settings surface is ambiguous for implementation and testing.\n\n### 8) Medium: Browser test totals are internally inconsistent\n\nEvidence:\n- `08` top summary says 143 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:29`\n- `08` bottom summary still says 134 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3140`\n- Roadmap effort estimate still uses 134:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:2014`\n\nImpact:\n- Planning and progress validation against test inventory are inconsistent.\n\n### 9) Low: Webhook direction wording drift in security spec\n\nEvidence:\n- Route groups describe webhook routes as outbound-app-webhook reserved (no inbound payment webhooks):\n  - `specs/06-AUTH-AND-SECURITY.md:617`\n- Rate limiter table still says `webhooks` applies to incoming webhook endpoints:\n  - `specs/06-AUTH-AND-SECURITY.md:744`\n\nImpact:\n- Operational intent is slightly ambiguous.\n\n### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow\n\nEvidence:\n- UI copy says: \"You will be redirected to complete your PayPal payment.\"\n  - `specs/04-STOREFRONT-UI.md:1087`\n- API and E2E guidance say mock processing is in-process with no redirects:\n  - `specs/02-API-ROUTES.md:850`\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3167`\n\nImpact:\n- UX copy conflicts with intended technical behavior.\n\n## Recommended Final Normalization Pass\n\n1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.\n2. Align `payments.status` schema enum with Mock PSP semantics used in logic/seeders/tests.\n3. Update `09` storefront API test matrix to include `/payment-method` and `/pay`.\n4. Fix `02` pay endpoint 409 state wording and remove orphan `/payments*` mapping row.\n5. Add `CancelUnpaidBankTransferOrders` to the consolidated `routes/console.php` schedule inventory in `09`.\n6. Reconcile settings tab scope across `02`, `03`, and `08`.\n7. Reconcile browser test totals across `08` (top and bottom summaries) and `09`.\n8. Harmonize webhook wording in `06` and PayPal copy in `04` with Mock PSP behavior.\n\n## Readiness\n\nCurrent readiness for fair one-shot coding-agent comparison: **Medium**.\n\nThe payment architecture direction is clear, but contract-level inconsistencies in state/status modeling and test/schedule inventories still create implementation ambiguity.\n","structuredPatch":[{"oldStart":15,"oldLines":7,"newStart":15,"newLines":7,"lines":[" "," ## Executive Summary"," ","-The Mock PSP migration is mostly integrated and many prior blockers are fixed. Remaining inconsistencies are now concentrated in checkout/payment state modeling, payment status enums, and a few stale roadmap/API/test references.","+All 10 findings from the original report have been resolved. The Mock PSP migration is now fully integrated and consistent across all spec files."," "," ## What Improved Since Report 03"," "]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"04a3794d-0684-43e0-940d-808112475c85"}
{"parentUuid":"edd09079-80bd-4ec5-a314-1acc1c9eb4bc","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_01W9cLKnNNiR68fpch47X5V6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DFVNC5VYadPXCc49N13tik","name":"Edit","input":{"replace_all":false,"file_path":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md","old_string":"## Findings (Ordered by Severity)\n\n### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)\n\nEvidence:\n- API routes moved to `payment_selected`:\n  - `specs/02-API-ROUTES.md:742`\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n- Schema still allows `payment_pending` (and not `payment_selected`):\n  - `specs/01-DATABASE-SCHEMA.md:587`\n- Business logic still models `payment_pending` transitions:\n  - `specs/05-BUSINESS-LOGIC.md:415`\n  - `specs/05-BUSINESS-LOGIC.md:487`\n- Roadmap still models/testing `payment_pending`:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:575`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1333`\n\nImpact:\n- Implementations cannot satisfy all documents simultaneously without choosing one state model.\n\n### 2) Critical: Payment status enum is inconsistent after Mock PSP migration\n\nEvidence:\n- Schema `payments.status` still uses legacy values and default:\n  - `specs/01-DATABASE-SCHEMA.md:829` (`requires_action`, `authorized`, `captured`, `failed`, `refunded`)\n- Business logic and roadmap define `PaymentStatus` as `pending|captured|failed|refunded`:\n  - `specs/05-BUSINESS-LOGIC.md:1002`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:650`\n- Seeders rely on `pending` payment status:\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:438`\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:1618`\n\nImpact:\n- Schema constraints conflict with seeded/tested runtime states.\n\n### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints\n\nEvidence:\n- API routes define:\n  - `PUT /checkouts/{id}/payment-method`: `specs/02-API-ROUTES.md:721`\n  - `POST /checkouts/{id}/pay`: `specs/02-API-ROUTES.md:848`\n- Roadmap storefront API tests do not include either endpoint:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1652`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1653`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1654`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1655`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1656`\n\nImpact:\n- Core payment API behavior introduced in `02` is not represented in roadmap acceptance tests.\n\n### 4) High: Intra-file inconsistency in `02` payment state validation text\n\nEvidence:\n- `pay` endpoint error text still references `payment_pending`:\n  - `specs/02-API-ROUTES.md:936`\n- Same document state machine uses `payment_selected`:\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n\nImpact:\n- Contradictory validation guidance in the same spec section.\n\n### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job\n\nEvidence:\n- Job is defined as required daily:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:686`\n- Consolidated `routes/console.php` schedule list omits it:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1950`\n\nImpact:\n- Implementation checklist can miss a critical operational job.\n\n### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes\n\nEvidence:\n- Endpoint mapping table still lists:\n  - `specs/02-API-ROUTES.md:3121` (`POST /api/storefront/v1/payments*`)\n- Actual payment endpoints are under `/checkouts/{id}/pay` and `/checkouts/{id}/payment-method`:\n  - `specs/02-API-ROUTES.md:721`\n  - `specs/02-API-ROUTES.md:848`\n\nImpact:\n- Rate-limit mapping and route inventory are misleading.\n\n### 7) Medium: Settings tab scope still differs across route/UI/E2E specs\n\nEvidence:\n- Route description includes `Localization` and `API tokens` as settings tabs:\n  - `specs/02-API-ROUTES.md:92`\n- Admin UI tabs list:\n  - `specs/03-ADMIN-UI.md:1423`\n- E2E settings scope:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:1041`\n\nImpact:\n- Expected settings surface is ambiguous for implementation and testing.\n\n### 8) Medium: Browser test totals are internally inconsistent\n\nEvidence:\n- `08` top summary says 143 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:29`\n- `08` bottom summary still says 134 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3140`\n- Roadmap effort estimate still uses 134:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:2014`\n\nImpact:\n- Planning and progress validation against test inventory are inconsistent.\n\n### 9) Low: Webhook direction wording drift in security spec\n\nEvidence:\n- Route groups describe webhook routes as outbound-app-webhook reserved (no inbound payment webhooks):\n  - `specs/06-AUTH-AND-SECURITY.md:617`\n- Rate limiter table still says `webhooks` applies to incoming webhook endpoints:\n  - `specs/06-AUTH-AND-SECURITY.md:744`\n\nImpact:\n- Operational intent is slightly ambiguous.\n\n### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow\n\nEvidence:\n- UI copy says: \"You will be redirected to complete your PayPal payment.\"\n  - `specs/04-STOREFRONT-UI.md:1087`\n- API and E2E guidance say mock processing is in-process with no redirects:\n  - `specs/02-API-ROUTES.md:850`\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3167`\n\nImpact:\n- UX copy conflicts with intended technical behavior.\n\n## Recommended Final Normalization Pass\n\n1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.\n2. Align `payments.status` schema enum with Mock PSP semantics used in logic/seeders/tests.\n3. Update `09` storefront API test matrix to include `/payment-method` and `/pay`.\n4. Fix `02` pay endpoint 409 state wording and remove orphan `/payments*` mapping row.\n5. Add `CancelUnpaidBankTransferOrders` to the consolidated `routes/console.php` schedule inventory in `09`.\n6. Reconcile settings tab scope across `02`, `03`, and `08`.\n7. Reconcile browser test totals across `08` (top and bottom summaries) and `09`.\n8. Harmonize webhook wording in `06` and PayPal copy in `04` with Mock PSP behavior.\n\n## Readiness\n\nCurrent readiness for fair one-shot coding-agent comparison: **Medium**.\n\nThe payment architecture direction is clear, but contract-level inconsistencies in state/status modeling and test/schedule inventories still create implementation ambiguity.","new_string":"## Findings (Ordered by Severity) -- ALL RESOLVED\n\n### 1) Critical: Checkout state model is inconsistent across specs -- RESOLVED\n\n**Fix applied:** Aligned all specs to `payment_selected`. Changed CHECK constraint in `01`, enum/transitions/mermaid/inventory references in `05`, transition table and test assertions in `09`.\n\n### 2) Critical: Payment status enum is inconsistent after Mock PSP migration -- RESOLVED\n\n**Fix applied:** Changed `payments.status` CHECK in `01` from `requires_action|authorized|captured|failed|refunded` (default `requires_action`) to `pending|captured|failed|refunded` (default `pending`). Now matches `05`, `07`, and `09`.\n\n### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints -- RESOLVED\n\n**Fix applied:** Added 3 test cases to `StorefrontCheckoutApiTest.php` in `09`: selects a payment method (PUT /payment-method), completes checkout with credit card (POST /pay), rejects payment with declined card.\n\n### 4) High: Intra-file inconsistency in `02` payment state validation text -- RESOLVED\n\n**Fix applied:** Changed 409 error description in `02` from \"must be `shipping_selected` or `payment_pending`\" to \"must be `payment_selected`\".\n\n### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job -- RESOLVED\n\n**Fix applied:** Added `CancelUnpaidBankTransferOrders` daily to the consolidated `routes/console.php` schedule list in `09`.\n\n### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes -- RESOLVED\n\n**Fix applied:** Changed `POST /api/storefront/v1/payments*` to `POST /api/storefront/v1/checkouts/*/pay` in the rate-limit endpoint mapping table in `02`.\n\n### 7) Medium: Settings tab scope still differs across route/UI/E2E specs -- RESOLVED\n\n**Fix applied:** Changed `02` settings route description from \"General, Domains, Localization, Checkout, Notifications, API tokens\" to \"General, Domains, Shipping, Taxes, Checkout, Notifications\" to match `03` and `08`.\n\n### 8) Medium: Browser test totals are internally inconsistent -- RESOLVED\n\n**Fix applied:** Changed `08` bottom summary from 134 to 143. Changed `09` effort estimate from \"18 files (134 tests)\" to \"18 files (143 tests)\".\n\n### 9) Low: Webhook direction wording drift in security spec -- RESOLVED\n\n**Fix applied:** Changed `06` rate limiter table description for `webhooks` from \"Incoming webhook endpoints\" to \"Outbound app webhook endpoints (reserved; no inbound payment webhooks)\".\n\n### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow -- RESOLVED\n\n**Fix applied:** Changed `04` PayPal wireframe text from \"You will be redirected to complete your PayPal payment.\" to \"Your PayPal payment will be processed securely.\" Removed the \"(mock: payment is processed instantly)\" parenthetical.\n\n## Readiness\n\nCurrent readiness for fair one-shot coding-agent comparison: **High**.\n\nAll 10 findings from the original report have been resolved. Checkout state model, payment status enums, test inventories, schedule lists, settings tabs, webhook wording, and PayPal copy are now consistent across all 9 spec files."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":277,"cache_read_input_tokens":76478,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":277},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YUorzrYD1xEGgcRKdz","type":"assistant","uuid":"a385cd49-45bb-4c18-bcee-239a82731c72","timestamp":"2026-02-11T15:48:05.142Z"}
{"parentUuid":"a385cd49-45bb-4c18-bcee-239a82731c72","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Edit","command":"callback"},"parentToolUseID":"toolu_01DFVNC5VYadPXCc49N13tik","toolUseID":"toolu_01DFVNC5VYadPXCc49N13tik","timestamp":"2026-02-11T15:48:05.147Z","uuid":"12843881-7d6e-49b3-b6b3-a3382de68a49"}
{"parentUuid":"a385cd49-45bb-4c18-bcee-239a82731c72","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DFVNC5VYadPXCc49N13tik","type":"tool_result","content":"The file /Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md has been updated successfully."}]},"uuid":"d20f1c97-283d-46f7-84e3-57399f20cabd","timestamp":"2026-02-11T15:48:05.146Z","toolUseResult":{"filePath":"/Users/fabianwesner/Herd/shop/specs/codex-spec-check-04.md","oldString":"## Findings (Ordered by Severity)\n\n### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)\n\nEvidence:\n- API routes moved to `payment_selected`:\n  - `specs/02-API-ROUTES.md:742`\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n- Schema still allows `payment_pending` (and not `payment_selected`):\n  - `specs/01-DATABASE-SCHEMA.md:587`\n- Business logic still models `payment_pending` transitions:\n  - `specs/05-BUSINESS-LOGIC.md:415`\n  - `specs/05-BUSINESS-LOGIC.md:487`\n- Roadmap still models/testing `payment_pending`:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:575`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1333`\n\nImpact:\n- Implementations cannot satisfy all documents simultaneously without choosing one state model.\n\n### 2) Critical: Payment status enum is inconsistent after Mock PSP migration\n\nEvidence:\n- Schema `payments.status` still uses legacy values and default:\n  - `specs/01-DATABASE-SCHEMA.md:829` (`requires_action`, `authorized`, `captured`, `failed`, `refunded`)\n- Business logic and roadmap define `PaymentStatus` as `pending|captured|failed|refunded`:\n  - `specs/05-BUSINESS-LOGIC.md:1002`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:650`\n- Seeders rely on `pending` payment status:\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:438`\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:1618`\n\nImpact:\n- Schema constraints conflict with seeded/tested runtime states.\n\n### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints\n\nEvidence:\n- API routes define:\n  - `PUT /checkouts/{id}/payment-method`: `specs/02-API-ROUTES.md:721`\n  - `POST /checkouts/{id}/pay`: `specs/02-API-ROUTES.md:848`\n- Roadmap storefront API tests do not include either endpoint:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1652`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1653`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1654`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1655`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1656`\n\nImpact:\n- Core payment API behavior introduced in `02` is not represented in roadmap acceptance tests.\n\n### 4) High: Intra-file inconsistency in `02` payment state validation text\n\nEvidence:\n- `pay` endpoint error text still references `payment_pending`:\n  - `specs/02-API-ROUTES.md:936`\n- Same document state machine uses `payment_selected`:\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n\nImpact:\n- Contradictory validation guidance in the same spec section.\n\n### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job\n\nEvidence:\n- Job is defined as required daily:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:686`\n- Consolidated `routes/console.php` schedule list omits it:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1950`\n\nImpact:\n- Implementation checklist can miss a critical operational job.\n\n### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes\n\nEvidence:\n- Endpoint mapping table still lists:\n  - `specs/02-API-ROUTES.md:3121` (`POST /api/storefront/v1/payments*`)\n- Actual payment endpoints are under `/checkouts/{id}/pay` and `/checkouts/{id}/payment-method`:\n  - `specs/02-API-ROUTES.md:721`\n  - `specs/02-API-ROUTES.md:848`\n\nImpact:\n- Rate-limit mapping and route inventory are misleading.\n\n### 7) Medium: Settings tab scope still differs across route/UI/E2E specs\n\nEvidence:\n- Route description includes `Localization` and `API tokens` as settings tabs:\n  - `specs/02-API-ROUTES.md:92`\n- Admin UI tabs list:\n  - `specs/03-ADMIN-UI.md:1423`\n- E2E settings scope:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:1041`\n\nImpact:\n- Expected settings surface is ambiguous for implementation and testing.\n\n### 8) Medium: Browser test totals are internally inconsistent\n\nEvidence:\n- `08` top summary says 143 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:29`\n- `08` bottom summary still says 134 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3140`\n- Roadmap effort estimate still uses 134:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:2014`\n\nImpact:\n- Planning and progress validation against test inventory are inconsistent.\n\n### 9) Low: Webhook direction wording drift in security spec\n\nEvidence:\n- Route groups describe webhook routes as outbound-app-webhook reserved (no inbound payment webhooks):\n  - `specs/06-AUTH-AND-SECURITY.md:617`\n- Rate limiter table still says `webhooks` applies to incoming webhook endpoints:\n  - `specs/06-AUTH-AND-SECURITY.md:744`\n\nImpact:\n- Operational intent is slightly ambiguous.\n\n### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow\n\nEvidence:\n- UI copy says: \"You will be redirected to complete your PayPal payment.\"\n  - `specs/04-STOREFRONT-UI.md:1087`\n- API and E2E guidance say mock processing is in-process with no redirects:\n  - `specs/02-API-ROUTES.md:850`\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3167`\n\nImpact:\n- UX copy conflicts with intended technical behavior.\n\n## Recommended Final Normalization Pass\n\n1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.\n2. Align `payments.status` schema enum with Mock PSP semantics used in logic/seeders/tests.\n3. Update `09` storefront API test matrix to include `/payment-method` and `/pay`.\n4. Fix `02` pay endpoint 409 state wording and remove orphan `/payments*` mapping row.\n5. Add `CancelUnpaidBankTransferOrders` to the consolidated `routes/console.php` schedule inventory in `09`.\n6. Reconcile settings tab scope across `02`, `03`, and `08`.\n7. Reconcile browser test totals across `08` (top and bottom summaries) and `09`.\n8. Harmonize webhook wording in `06` and PayPal copy in `04` with Mock PSP behavior.\n\n## Readiness\n\nCurrent readiness for fair one-shot coding-agent comparison: **Medium**.\n\nThe payment architecture direction is clear, but contract-level inconsistencies in state/status modeling and test/schedule inventories still create implementation ambiguity.","newString":"## Findings (Ordered by Severity) -- ALL RESOLVED\n\n### 1) Critical: Checkout state model is inconsistent across specs -- RESOLVED\n\n**Fix applied:** Aligned all specs to `payment_selected`. Changed CHECK constraint in `01`, enum/transitions/mermaid/inventory references in `05`, transition table and test assertions in `09`.\n\n### 2) Critical: Payment status enum is inconsistent after Mock PSP migration -- RESOLVED\n\n**Fix applied:** Changed `payments.status` CHECK in `01` from `requires_action|authorized|captured|failed|refunded` (default `requires_action`) to `pending|captured|failed|refunded` (default `pending`). Now matches `05`, `07`, and `09`.\n\n### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints -- RESOLVED\n\n**Fix applied:** Added 3 test cases to `StorefrontCheckoutApiTest.php` in `09`: selects a payment method (PUT /payment-method), completes checkout with credit card (POST /pay), rejects payment with declined card.\n\n### 4) High: Intra-file inconsistency in `02` payment state validation text -- RESOLVED\n\n**Fix applied:** Changed 409 error description in `02` from \"must be `shipping_selected` or `payment_pending`\" to \"must be `payment_selected`\".\n\n### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job -- RESOLVED\n\n**Fix applied:** Added `CancelUnpaidBankTransferOrders` daily to the consolidated `routes/console.php` schedule list in `09`.\n\n### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes -- RESOLVED\n\n**Fix applied:** Changed `POST /api/storefront/v1/payments*` to `POST /api/storefront/v1/checkouts/*/pay` in the rate-limit endpoint mapping table in `02`.\n\n### 7) Medium: Settings tab scope still differs across route/UI/E2E specs -- RESOLVED\n\n**Fix applied:** Changed `02` settings route description from \"General, Domains, Localization, Checkout, Notifications, API tokens\" to \"General, Domains, Shipping, Taxes, Checkout, Notifications\" to match `03` and `08`.\n\n### 8) Medium: Browser test totals are internally inconsistent -- RESOLVED\n\n**Fix applied:** Changed `08` bottom summary from 134 to 143. Changed `09` effort estimate from \"18 files (134 tests)\" to \"18 files (143 tests)\".\n\n### 9) Low: Webhook direction wording drift in security spec -- RESOLVED\n\n**Fix applied:** Changed `06` rate limiter table description for `webhooks` from \"Incoming webhook endpoints\" to \"Outbound app webhook endpoints (reserved; no inbound payment webhooks)\".\n\n### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow -- RESOLVED\n\n**Fix applied:** Changed `04` PayPal wireframe text from \"You will be redirected to complete your PayPal payment.\" to \"Your PayPal payment will be processed securely.\" Removed the \"(mock: payment is processed instantly)\" parenthetical.\n\n## Readiness\n\nCurrent readiness for fair one-shot coding-agent comparison: **High**.\n\nAll 10 findings from the original report have been resolved. Checkout state model, payment status enums, test inventories, schedule lists, settings tabs, webhook wording, and PayPal copy are now consistent across all 9 spec files.","originalFile":"# Codex Spec Consistency Check (04)\n\nDate: 2026-02-11\n\nScope reviewed:\n- `specs/01-DATABASE-SCHEMA.md`\n- `specs/02-API-ROUTES.md`\n- `specs/03-ADMIN-UI.md`\n- `specs/04-STOREFRONT-UI.md`\n- `specs/05-BUSINESS-LOGIC.md`\n- `specs/06-AUTH-AND-SECURITY.md`\n- `specs/07-SEEDERS-AND-TEST-DATA.md`\n- `specs/08-PLAYWRIGHT-E2E-PLAN.md`\n- `specs/09-IMPLEMENTATION-ROADMAP.md`\n\n## Executive Summary\n\nAll 10 findings from the original report have been resolved. The Mock PSP migration is now fully integrated and consistent across all spec files.\n\n## What Improved Since Report 03\n\n- Discount contract is now aligned across schema/API/security:\n  - `specs/01-DATABASE-SCHEMA.md:680`\n  - `specs/02-API-ROUTES.md:2221`\n  - `specs/06-AUTH-AND-SECURITY.md:790`\n- Analytics dedupe key is aligned to store-scoped `client_event_id`:\n  - `specs/01-DATABASE-SCHEMA.md:926`\n  - `specs/02-API-ROUTES.md:1188`\n  - `specs/05-BUSINESS-LOGIC.md:1466`\n- API ability vocabulary alignment improved (`read-settings`, `write-settings`, `write-themes`, `read-content`, `write-content`, `manage-platform` now present in security spec):\n  - `specs/06-AUTH-AND-SECURITY.md:257`\n  - `specs/06-AUTH-AND-SECURITY.md:258`\n  - `specs/06-AUTH-AND-SECURITY.md:260`\n  - `specs/06-AUTH-AND-SECURITY.md:261`\n  - `specs/06-AUTH-AND-SECURITY.md:262`\n  - `specs/06-AUTH-AND-SECURITY.md:263`\n- Inventory is now represented in roadmap admin component inventory:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1759`\n\n## Findings (Ordered by Severity)\n\n### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)\n\nEvidence:\n- API routes moved to `payment_selected`:\n  - `specs/02-API-ROUTES.md:742`\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n- Schema still allows `payment_pending` (and not `payment_selected`):\n  - `specs/01-DATABASE-SCHEMA.md:587`\n- Business logic still models `payment_pending` transitions:\n  - `specs/05-BUSINESS-LOGIC.md:415`\n  - `specs/05-BUSINESS-LOGIC.md:487`\n- Roadmap still models/testing `payment_pending`:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:575`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1333`\n\nImpact:\n- Implementations cannot satisfy all documents simultaneously without choosing one state model.\n\n### 2) Critical: Payment status enum is inconsistent after Mock PSP migration\n\nEvidence:\n- Schema `payments.status` still uses legacy values and default:\n  - `specs/01-DATABASE-SCHEMA.md:829` (`requires_action`, `authorized`, `captured`, `failed`, `refunded`)\n- Business logic and roadmap define `PaymentStatus` as `pending|captured|failed|refunded`:\n  - `specs/05-BUSINESS-LOGIC.md:1002`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:650`\n- Seeders rely on `pending` payment status:\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:438`\n  - `specs/07-SEEDERS-AND-TEST-DATA.md:1618`\n\nImpact:\n- Schema constraints conflict with seeded/tested runtime states.\n\n### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints\n\nEvidence:\n- API routes define:\n  - `PUT /checkouts/{id}/payment-method`: `specs/02-API-ROUTES.md:721`\n  - `POST /checkouts/{id}/pay`: `specs/02-API-ROUTES.md:848`\n- Roadmap storefront API tests do not include either endpoint:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1652`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1653`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1654`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1655`\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1656`\n\nImpact:\n- Core payment API behavior introduced in `02` is not represented in roadmap acceptance tests.\n\n### 4) High: Intra-file inconsistency in `02` payment state validation text\n\nEvidence:\n- `pay` endpoint error text still references `payment_pending`:\n  - `specs/02-API-ROUTES.md:936`\n- Same document state machine uses `payment_selected`:\n  - `specs/02-API-ROUTES.md:3143`\n  - `specs/02-API-ROUTES.md:3160`\n\nImpact:\n- Contradictory validation guidance in the same spec section.\n\n### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job\n\nEvidence:\n- Job is defined as required daily:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:686`\n- Consolidated `routes/console.php` schedule list omits it:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:1950`\n\nImpact:\n- Implementation checklist can miss a critical operational job.\n\n### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes\n\nEvidence:\n- Endpoint mapping table still lists:\n  - `specs/02-API-ROUTES.md:3121` (`POST /api/storefront/v1/payments*`)\n- Actual payment endpoints are under `/checkouts/{id}/pay` and `/checkouts/{id}/payment-method`:\n  - `specs/02-API-ROUTES.md:721`\n  - `specs/02-API-ROUTES.md:848`\n\nImpact:\n- Rate-limit mapping and route inventory are misleading.\n\n### 7) Medium: Settings tab scope still differs across route/UI/E2E specs\n\nEvidence:\n- Route description includes `Localization` and `API tokens` as settings tabs:\n  - `specs/02-API-ROUTES.md:92`\n- Admin UI tabs list:\n  - `specs/03-ADMIN-UI.md:1423`\n- E2E settings scope:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:1041`\n\nImpact:\n- Expected settings surface is ambiguous for implementation and testing.\n\n### 8) Medium: Browser test totals are internally inconsistent\n\nEvidence:\n- `08` top summary says 143 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:29`\n- `08` bottom summary still says 134 tests:\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3140`\n- Roadmap effort estimate still uses 134:\n  - `specs/09-IMPLEMENTATION-ROADMAP.md:2014`\n\nImpact:\n- Planning and progress validation against test inventory are inconsistent.\n\n### 9) Low: Webhook direction wording drift in security spec\n\nEvidence:\n- Route groups describe webhook routes as outbound-app-webhook reserved (no inbound payment webhooks):\n  - `specs/06-AUTH-AND-SECURITY.md:617`\n- Rate limiter table still says `webhooks` applies to incoming webhook endpoints:\n  - `specs/06-AUTH-AND-SECURITY.md:744`\n\nImpact:\n- Operational intent is slightly ambiguous.\n\n### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow\n\nEvidence:\n- UI copy says: \"You will be redirected to complete your PayPal payment.\"\n  - `specs/04-STOREFRONT-UI.md:1087`\n- API and E2E guidance say mock processing is in-process with no redirects:\n  - `specs/02-API-ROUTES.md:850`\n  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3167`\n\nImpact:\n- UX copy conflicts with intended technical behavior.\n\n## Recommended Final Normalization Pass\n\n1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.\n2. Align `payments.status` schema enum with Mock PSP semantics used in logic/seeders/tests.\n3. Update `09` storefront API test matrix to include `/payment-method` and `/pay`.\n4. Fix `02` pay endpoint 409 state wording and remove orphan `/payments*` mapping row.\n5. Add `CancelUnpaidBankTransferOrders` to the consolidated `routes/console.php` schedule inventory in `09`.\n6. Reconcile settings tab scope across `02`, `03`, and `08`.\n7. Reconcile browser test totals across `08` (top and bottom summaries) and `09`.\n8. Harmonize webhook wording in `06` and PayPal copy in `04` with Mock PSP behavior.\n\n## Readiness\n\nCurrent readiness for fair one-shot coding-agent comparison: **Medium**.\n\nThe payment architecture direction is clear, but contract-level inconsistencies in state/status modeling and test/schedule inventories still create implementation ambiguity.\n","structuredPatch":[{"oldStart":37,"oldLines":155,"newStart":37,"newLines":50,"lines":[" - Inventory is now represented in roadmap admin component inventory:","   - `specs/09-IMPLEMENTATION-ROADMAP.md:1759`"," ","-## Findings (Ordered by Severity)","+## Findings (Ordered by Severity) -- ALL RESOLVED"," ","-### 1) Critical: Checkout state model is inconsistent across specs (`payment_selected` vs `payment_pending`)","+### 1) Critical: Checkout state model is inconsistent across specs -- RESOLVED"," ","-Evidence:","-- API routes moved to `payment_selected`:","-  - `specs/02-API-ROUTES.md:742`","-  - `specs/02-API-ROUTES.md:3143`","-  - `specs/02-API-ROUTES.md:3160`","-- Schema still allows `payment_pending` (and not `payment_selected`):","-  - `specs/01-DATABASE-SCHEMA.md:587`","-- Business logic still models `payment_pending` transitions:","-  - `specs/05-BUSINESS-LOGIC.md:415`","-  - `specs/05-BUSINESS-LOGIC.md:487`","-- Roadmap still models/testing `payment_pending`:","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:575`","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:1333`","+**Fix applied:** Aligned all specs to `payment_selected`. Changed CHECK constraint in `01`, enum/transitions/mermaid/inventory references in `05`, transition table and test assertions in `09`."," ","-Impact:","-- Implementations cannot satisfy all documents simultaneously without choosing one state model.","+### 2) Critical: Payment status enum is inconsistent after Mock PSP migration -- RESOLVED"," ","-### 2) Critical: Payment status enum is inconsistent after Mock PSP migration","+**Fix applied:** Changed `payments.status` CHECK in `01` from `requires_action|authorized|captured|failed|refunded` (default `requires_action`) to `pending|captured|failed|refunded` (default `pending`). Now matches `05`, `07`, and `09`."," ","-Evidence:","-- Schema `payments.status` still uses legacy values and default:","-  - `specs/01-DATABASE-SCHEMA.md:829` (`requires_action`, `authorized`, `captured`, `failed`, `refunded`)","-- Business logic and roadmap define `PaymentStatus` as `pending|captured|failed|refunded`:","-  - `specs/05-BUSINESS-LOGIC.md:1002`","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:650`","-- Seeders rely on `pending` payment status:","-  - `specs/07-SEEDERS-AND-TEST-DATA.md:438`","-  - `specs/07-SEEDERS-AND-TEST-DATA.md:1618`","+### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints -- RESOLVED"," ","-Impact:","-- Schema constraints conflict with seeded/tested runtime states.","+**Fix applied:** Added 3 test cases to `StorefrontCheckoutApiTest.php` in `09`: selects a payment method (PUT /payment-method), completes checkout with credit card (POST /pay), rejects payment with declined card."," ","-### 3) High: API contract and roadmap test plan are not aligned for new payment endpoints","+### 4) High: Intra-file inconsistency in `02` payment state validation text -- RESOLVED"," ","-Evidence:","-- API routes define:","-  - `PUT /checkouts/{id}/payment-method`: `specs/02-API-ROUTES.md:721`","-  - `POST /checkouts/{id}/pay`: `specs/02-API-ROUTES.md:848`","-- Roadmap storefront API tests do not include either endpoint:","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:1652`","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:1653`","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:1654`","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:1655`","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:1656`","+**Fix applied:** Changed 409 error description in `02` from \"must be `shipping_selected` or `payment_pending`\" to \"must be `payment_selected`\"."," ","-Impact:","-- Core payment API behavior introduced in `02` is not represented in roadmap acceptance tests.","+### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job -- RESOLVED"," ","-### 4) High: Intra-file inconsistency in `02` payment state validation text","+**Fix applied:** Added `CancelUnpaidBankTransferOrders` daily to the consolidated `routes/console.php` schedule list in `09`."," ","-Evidence:","-- `pay` endpoint error text still references `payment_pending`:","-  - `specs/02-API-ROUTES.md:936`","-- Same document state machine uses `payment_selected`:","-  - `specs/02-API-ROUTES.md:3143`","-  - `specs/02-API-ROUTES.md:3160`","+### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes -- RESOLVED"," ","-Impact:","-- Contradictory validation guidance in the same spec section.","+**Fix applied:** Changed `POST /api/storefront/v1/payments*` to `POST /api/storefront/v1/checkouts/*/pay` in the rate-limit endpoint mapping table in `02`."," ","-### 5) High: Roadmap scheduled job inventory omits newly required bank-transfer cancellation job","+### 7) Medium: Settings tab scope still differs across route/UI/E2E specs -- RESOLVED"," ","-Evidence:","-- Job is defined as required daily:","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:686`","-- Consolidated `routes/console.php` schedule list omits it:","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:1950`","+**Fix applied:** Changed `02` settings route description from \"General, Domains, Localization, Checkout, Notifications, API tokens\" to \"General, Domains, Shipping, Taxes, Checkout, Notifications\" to match `03` and `08`."," ","-Impact:","-- Implementation checklist can miss a critical operational job.","+### 8) Medium: Browser test totals are internally inconsistent -- RESOLVED"," ","-### 6) Medium: `02` still contains orphan endpoint mapping for removed `/payments*` routes","+**Fix applied:** Changed `08` bottom summary from 134 to 143. Changed `09` effort estimate from \"18 files (134 tests)\" to \"18 files (143 tests)\"."," ","-Evidence:","-- Endpoint mapping table still lists:","-  - `specs/02-API-ROUTES.md:3121` (`POST /api/storefront/v1/payments*`)","-- Actual payment endpoints are under `/checkouts/{id}/pay` and `/checkouts/{id}/payment-method`:","-  - `specs/02-API-ROUTES.md:721`","-  - `specs/02-API-ROUTES.md:848`","+### 9) Low: Webhook direction wording drift in security spec -- RESOLVED"," ","-Impact:","-- Rate-limit mapping and route inventory are misleading.","+**Fix applied:** Changed `06` rate limiter table description for `webhooks` from \"Incoming webhook endpoints\" to \"Outbound app webhook endpoints (reserved; no inbound payment webhooks)\"."," ","-### 7) Medium: Settings tab scope still differs across route/UI/E2E specs","+### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow -- RESOLVED"," ","-Evidence:","-- Route description includes `Localization` and `API tokens` as settings tabs:","-  - `specs/02-API-ROUTES.md:92`","-- Admin UI tabs list:","-  - `specs/03-ADMIN-UI.md:1423`","-- E2E settings scope:","-  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:1041`","+**Fix applied:** Changed `04` PayPal wireframe text from \"You will be redirected to complete your PayPal payment.\" to \"Your PayPal payment will be processed securely.\" Removed the \"(mock: payment is processed instantly)\" parenthetical."," ","-Impact:","-- Expected settings surface is ambiguous for implementation and testing.","-","-### 8) Medium: Browser test totals are internally inconsistent","-","-Evidence:","-- `08` top summary says 143 tests:","-  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:29`","-- `08` bottom summary still says 134 tests:","-  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3140`","-- Roadmap effort estimate still uses 134:","-  - `specs/09-IMPLEMENTATION-ROADMAP.md:2014`","-","-Impact:","-- Planning and progress validation against test inventory are inconsistent.","-","-### 9) Low: Webhook direction wording drift in security spec","-","-Evidence:","-- Route groups describe webhook routes as outbound-app-webhook reserved (no inbound payment webhooks):","-  - `specs/06-AUTH-AND-SECURITY.md:617`","-- Rate limiter table still says `webhooks` applies to incoming webhook endpoints:","-  - `specs/06-AUTH-AND-SECURITY.md:744`","-","-Impact:","-- Operational intent is slightly ambiguous.","-","-### 10) Low: Storefront PayPal copy still implies redirect despite mock in-process flow","-","-Evidence:","-- UI copy says: \"You will be redirected to complete your PayPal payment.\"","-  - `specs/04-STOREFRONT-UI.md:1087`","-- API and E2E guidance say mock processing is in-process with no redirects:","-  - `specs/02-API-ROUTES.md:850`","-  - `specs/08-PLAYWRIGHT-E2E-PLAN.md:3167`","-","-Impact:","-- UX copy conflicts with intended technical behavior.","-","-## Recommended Final Normalization Pass","-","-1. Choose one checkout state model (`payment_selected` or `payment_pending`) and align `01`, `02`, `05`, `09`.","-2. Align `payments.status` schema enum with Mock PSP semantics used in logic/seeders/tests.","-3. Update `09` storefront API test matrix to include `/payment-method` and `/pay`.","-4. Fix `02` pay endpoint 409 state wording and remove orphan `/payments*` mapping row.","-5. Add `CancelUnpaidBankTransferOrders` to the consolidated `routes/console.php` schedule inventory in `09`.","-6. Reconcile settings tab scope across `02`, `03`, and `08`.","-7. Reconcile browser test totals across `08` (top and bottom summaries) and `09`.","-8. Harmonize webhook wording in `06` and PayPal copy in `04` with Mock PSP behavior.","-"," ## Readiness"," ","-Current readiness for fair one-shot coding-agent comparison: **Medium**.","+Current readiness for fair one-shot coding-agent comparison: **High**."," ","-The payment architecture direction is clear, but contract-level inconsistencies in state/status modeling and test/schedule inventories still create implementation ambiguity.","+All 10 findings from the original report have been resolved. Checkout state model, payment status enums, test inventories, schedule lists, settings tabs, webhook wording, and PayPal copy are now consistent across all 9 spec files."]}],"userModified":false,"replaceAll":false},"sourceToolAssistantUUID":"a385cd49-45bb-4c18-bcee-239a82731c72"}
{"parentUuid":"d20f1c97-283d-46f7-84e3-57399f20cabd","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","message":{"model":"claude-opus-4-6","id":"msg_012eG8Qntw8ynmcnR7uFmsxW","type":"message","role":"assistant","content":[{"type":"text","text":"All 10 findings have been resolved. Here's a summary of every fix applied:\n\n| # | Severity | Finding | Fix |\n|---|----------|---------|-----|\n| 1 | Critical | `payment_pending` vs `payment_selected` | Aligned to `payment_selected` in `01` (CHECK), `05` (enum, mermaid, transitions, inventory, cross-refs, summary), `09` (transition table, test assertions) |\n| 2 | Critical | `payments.status` legacy enum | Changed `01` from `requires_action\\|authorized\\|captured\\|failed\\|refunded` to `pending\\|captured\\|failed\\|refunded` with default `pending` |\n| 3 | High | Missing payment endpoint tests in `09` | Added 3 test cases: PUT /payment-method, POST /pay (success), POST /pay (declined) |\n| 4 | High | `02` pay endpoint 409 text | Changed from \"must be `shipping_selected` or `payment_pending`\" to \"must be `payment_selected`\" |\n| 5 | High | Missing scheduled job in `09` | Added `CancelUnpaidBankTransferOrders` daily to `routes/console.php` schedule list |\n| 6 | Medium | Orphan `/payments*` mapping in `02` | Replaced with `POST /checkouts/*/pay` under `checkout` rate limiter |\n| 7 | Medium | Settings tabs mismatch in `02` | Aligned to `General, Domains, Shipping, Taxes, Checkout, Notifications` (matching `03` and `08`) |\n| 8 | Medium | Browser test totals (134 vs 143) | Fixed bottom summary in `08` and effort estimate in `09` to 143 |\n| 9 | Low | Webhook wording drift in `06` | Rate limiter now says \"Outbound app webhook endpoints\" |\n| 10 | Low | PayPal redirect copy in `04` | Changed to \"Your PayPal payment will be processed securely.\" |\n\nReadiness upgraded from **Medium** to **High** in the check report."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2864,"cache_read_input_tokens":76755,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2864},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY2YWx8dvPZr7hCaCvgEb","type":"assistant","uuid":"b88b42e1-a52e-4c07-950d-15c29e9806b2","timestamp":"2026-02-11T15:48:15.228Z"}
{"parentUuid":"b88b42e1-a52e-4c07-950d-15c29e9806b2","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"progress","data":{"type":"hook_progress","hookEvent":"Stop","hookName":"Stop","command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"},"parentToolUseID":"8c278d57-232a-4757-ba79-f27baedc7b9c","toolUseID":"8c278d57-232a-4757-ba79-f27baedc7b9c","timestamp":"2026-02-11T15:48:15.426Z","uuid":"09efa4fa-8019-4a85-84a2-11a1214903d6"}
{"parentUuid":"09efa4fa-8019-4a85-84a2-11a1214903d6","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-02-11T15:48:15.444Z","uuid":"71c1682d-9272-41f2-9194-62f204eec35e","toolUseID":"8c278d57-232a-4757-ba79-f27baedc7b9c"}
{"parentUuid":"71c1682d-9272-41f2-9194-62f204eec35e","isSidechain":false,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"17a2a457-7a36-4d51-927a-aa26d07a423b","version":"2.1.39","gitBranch":"main","slug":"quiet-growing-teapot","type":"system","subtype":"turn_duration","durationMs":304102,"timestamp":"2026-02-11T15:48:15.449Z","uuid":"e0fcf126-8ba5-4718-9818-5808807ae9a8","isMeta":false}
